/home/altere25/sportzsoftlivemeet.com/wp-content/plugins/stifli-flex-mcp/models
NameSizeModeActions
integrations/-0755rm
snippets/-0755rm
woocommerce/-0755rm
class-change-tracker.php574470644editdlrm
dispatcher.php6360644editdlrm
frame.php4180644editdlrm
model.php3908690644editdlrm
req.php2270644editdlrm
utils.php198860644editdlrm
Edit: /home/altere25/sportzsoftlivemeet.com/wp-content/plugins/stifli-flex-mcp/models/model.php (390869B)
maybeLoadFile( 'snippets/snippets.php', 'StifliFlexMcp_Snippets' ); } private function maybeLoadWooCommerceModules() { if ( ! class_exists( 'WooCommerce' ) ) { return false; } $loaded = false; $modules = array( 'woocommerce/wc-products.php' => 'StifliFlexMcp_WC_Products', 'woocommerce/wc-orders.php' => 'StifliFlexMcp_WC_Orders', 'woocommerce/wc-customers-coupons.php' => 'StifliFlexMcp_WC_Coupons', 'woocommerce/wc-system.php' => 'StifliFlexMcp_WC_System', ); foreach ( $modules as $relative_path => $class_name ) { if ( $this->maybeLoadFile( $relative_path, $class_name ) ) { $loaded = true; } } return $loaded; } /** * Dispatch a Custom Tool (Webhook/API call or WordPress Action) */ private function dispatchCustomTool($toolName, $args, $rpcId, $response) { global $wpdb; $table = $wpdb->prefix . 'sflmcp_custom_tools'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name safe, toolName is sanitized input. $row = $wpdb->get_row($wpdb->prepare("SELECT * FROM `$table` WHERE tool_name = %s AND enabled = 1", $toolName)); if (!$row) { $response['error'] = array('code' => -32601, 'message' => 'Custom tool not found or disabled: ' . $toolName); return $response; } $method = strtoupper($row->method); $endpoint = $row->endpoint; // ===================================================== // TYPE: ACTION - Execute WordPress do_action() // Allows calling ANY WordPress/plugin action hook // ===================================================== if ($method === 'ACTION') { // The endpoint is the action name (sanitized) $action_name = sanitize_key($endpoint); if (empty($action_name)) { $response['error'] = array('code' => -32602, 'message' => 'Action name cannot be empty'); return $response; } // Check if this action has any registered callbacks $has_action = has_action($action_name); // If no handlers, check if it's a known plugin action and warn accordingly $warning_msg = ''; if (!$has_action) { $known_plugins = array( 'woocommerce_' => array('WooCommerce', 'woocommerce/woocommerce.php'), 'w3tc_' => array('W3 Total Cache', 'w3-total-cache/w3-total-cache.php'), 'wp_super_cache_' => array('WP Super Cache', 'wp-super-cache/wp-cache.php'), 'wpcf7_' => array('Contact Form 7', 'contact-form-7/wp-contact-form-7.php'), 'yoast_' => array('Yoast SEO', 'wordpress-seo/wp-seo.php'), 'rank_math_' => array('Rank Math', 'seo-by-rank-math/rank-math.php'), 'jetpack_' => array('Jetpack', 'jetpack/jetpack.php'), 'wpml_' => array('WPML', 'sitepress-multilingual-cms/sitepress.php'), ); foreach ($known_plugins as $prefix => $plugin_info) { if (strpos($action_name, $prefix) === 0) { $plugin_name = $plugin_info[0]; $plugin_file = $plugin_info[1]; if (!is_plugin_active($plugin_file)) { $warning_msg = sprintf('Plugin "%s" is not active. ', $plugin_name); } else { $warning_msg = sprintf('Plugin "%s" is active but this hook has no handlers. The hook may only be available in specific contexts (admin, frontend, cron). ', $plugin_name); } break; } } if (empty($warning_msg)) { $warning_msg = 'No handlers registered for this action. It may be a custom hook that requires your own handler. '; } } // Allow filter to capture/modify results from actions // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- sflmcp is the plugin prefix $result = apply_filters( 'sflmcp_action_result', null, $action_name, $args ); // Execute the WordPress action with args // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- intentionally calling dynamic action hooks as per Custom Tools feature do_action( $action_name, $args ); // Build response if ($result !== null) { $response['result'] = array('content' => array(array('type' => 'text', 'text' => is_string($result) ? $result : wp_json_encode($result)))); } else { if ($has_action) { $status = 'Action executed successfully: ' . $action_name; } else { $status = 'Warning: ' . $warning_msg . 'Action triggered: ' . $action_name; } $response['result'] = array('content' => array(array('type' => 'text', 'text' => $status))); } return $response; } // ===================================================== // TYPE: HTTP (GET/POST/PUT/DELETE) - Remote Request // ===================================================== $url = $endpoint; $headers_raw = $row->headers; // Parse headers from newline-separated format $headers = array(); if (!empty($headers_raw)) { $lines = explode("\n", $headers_raw); foreach ($lines as $line) { $line = trim($line); if (strpos($line, ':') !== false) { list($key, $val) = explode(':', $line, 2); $headers[trim($key)] = trim($val); } } } // Replace {placeholder} in URL with args if (is_array($args)) { foreach ($args as $key => $value) { $url = str_replace('{' . $key . '}', rawurlencode((string) $value), $url); } } // Execute request $request_args = array( 'method' => $method, 'headers' => $headers, 'timeout' => 30, 'user-agent' => 'StifLi-Flex-MCP/1.0.5; ' . get_bloginfo('url') ); if (in_array($method, array('POST', 'PUT', 'PATCH'), true)) { $request_args['body'] = wp_json_encode($args); if (!isset($headers['Content-Type'])) { $request_args['headers']['Content-Type'] = 'application/json'; } } $remote_response = wp_remote_request($url, $request_args); if (is_wp_error($remote_response)) { $response['error'] = array('code' => -32000, 'message' => 'External tool error: ' . $remote_response->get_error_message()); return $response; } $code = wp_remote_retrieve_response_code($remote_response); $body = wp_remote_retrieve_body($remote_response); // Try to parse JSON response $decoded = json_decode($body, true); $final_content = ($decoded !== null) ? wp_json_encode($decoded, JSON_PRETTY_PRINT) : $body; if ($code >= 400) { $response['result'] = array('content' => array( array('type' => 'text', 'text' => "Error $code: $final_content") ), 'isError' => true); } else { $response['result'] = array('content' => array( array('type' => 'text', 'text' => $final_content) )); } return $response; } /** * Clasificación de intención y confirmación por tool. */ public function getIntentForTool(string $name): array { // Escritura/mutación $WRITE = array( 'wp_create_post','wp_update_post','wp_delete_post', 'wp_set_featured_image', 'wp_tec_save_event','wp_tec_save_entity','wp_tec_trash_event', 'wp_create_comment','wp_update_comment','wp_delete_comment', 'wp_rm_update_post_seo', // Yoast SEO write 'yoast_set_meta', // ACF write 'acf_update_field', // Gravity Forms write 'gf_update_entry', // Removed for WordPress.org compliance: wp_create_user, wp_update_user, wp_delete_user 'wp_upload_image_from_url', 'wp_upload_image', 'wp_generate_image', 'wp_generate_video', // Removed: wp_activate_plugin, wp_deactivate_plugin, wp_install_plugin, wp_install_theme, wp_switch_theme (WordPress.org compliance) 'wp_update_option', 'wp_update_post_meta','wp_delete_post_meta', 'wp_create_term','wp_delete_term', 'wp_update_term', 'wp_update_term_meta','wp_delete_term_meta', 'wp_create_nav_menu','wp_add_nav_menu_item','wp_update_nav_menu_item','wp_delete_nav_menu_item','wp_delete_nav_menu', 'wp_reorder_menu_items', 'wp_create_page','wp_update_page','wp_delete_page', 'wp_create_category','wp_update_category','wp_delete_category', 'wp_create_tag','wp_update_tag','wp_delete_tag', 'wp_update_media_item','wp_delete_media_item', 'wp_update_settings', // WordPress - Additional write operations 'wp_update_user_meta','wp_delete_user_meta', 'wp_restore_post_revision', // WooCommerce write operations 'wc_create_product','wc_update_product','wc_delete_product','wc_batch_update_products', 'wc_create_product_variation','wc_update_product_variation','wc_delete_product_variation','wc_batch_update_variations', 'wc_create_product_attribute','wc_set_product_attributes', 'wc_create_product_category','wc_update_product_category','wc_delete_product_category', 'wc_create_product_tag','wc_update_product_tag','wc_delete_product_tag', 'wc_create_product_review','wc_update_product_review','wc_delete_product_review', 'wc_create_order','wc_update_order','wc_delete_order','wc_batch_update_orders', 'wc_create_order_note','wc_delete_order_note', // Removed for WordPress.org compliance: wc_create_customer, wc_update_customer, wc_delete_customer 'wc_create_coupon','wc_update_coupon','wc_delete_coupon','wc_empty_coupon_trash', 'wc_create_tax_rate','wc_update_tax_rate','wc_delete_tax_rate', 'wc_create_shipping_zone','wc_update_shipping_zone','wc_delete_shipping_zone', 'wc_update_payment_gateway', 'wc_run_system_status_tool', 'wc_update_setting_option', 'wc_create_webhook','wc_update_webhook','wc_delete_webhook', // WooCommerce - Stock & Refunds 'wc_update_stock','wc_set_stock_status', 'wc_create_refund','wc_delete_refund', // Snippet write operations 'snippet_create','snippet_update','snippet_delete', 'snippet_activate','snippet_deactivate', // Changelog write operations 'mcp_rollback_change','mcp_redo_change','mcp_rollback_session', ); // Lectura sensible (requiere permisos elevados o toca red externa) $SENSITIVE_READ = array( 'wp_get_option', // requiere manage_options en dispatch 'wp_get_plugin_settings', // plugin options may contain secrets 'wp_get_post_meta', // requiere manage_options en dispatch 'wp_get_settings', // requiere manage_options 'wp_rm_get_head', 'wp_rm_get_post_seo', // Yoast SEO reads 'yoast_get_meta', 'yoast_reindex', // ACF reads 'acf_get_field_groups', 'acf_get_fields', // WPForms reads 'wpforms_list_forms', 'wpforms_get_entries', // Gravity Forms reads 'gf_list_forms', 'gf_get_entries', // Forminator reads 'forminator_list_forms', 'forminator_get_entries', 'fetch', // red externa: tratar como lectura sensible // WordPress - Additional sensitive reads 'wp_get_user_meta', // user privacy data 'wp_get_site_health', // system information 'wp_get_term_meta', // may include private/encoded data // WooCommerce sensitive reads (wc_get_customers removed for WordPress.org compliance) 'wc_get_orders', // order data privacy 'wc_get_order_notes', // order notes may contain sensitive info 'wc_get_system_status', // system information 'wc_get_settings', // WooCommerce settings // Snippet sensitive reads (code content) 'snippet_list','snippet_get', // Changelog sensitive reads 'mcp_get_changelog','mcp_get_change_detail' ); if (in_array($name, $WRITE, true)) { return array('intent' => 'write', 'requires_confirmation' => true); } if (in_array($name, $SENSITIVE_READ, true)) { return array('intent' => 'sensitive_read', 'requires_confirmation' => true); } return array('intent' => 'read', 'requires_confirmation' => false); } /** * Devuelve la lista de tools con categoría + intención + confirmación. * Filtra por herramientas habilitadas en wp_sflmcp_tools. */ public function getToolsList() { global $wpdb; $tools = $this->getTools(); if (!is_array($tools)) { return []; } // Get enabled tools from database $table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools', false); $enabled_tools = array(); // Check if table exists first. $like = $wpdb->esc_like($table); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- schema introspection requires SHOW TABLES with LIKE pattern. $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like)) === $table; if ($table_exists) { $tools_tbl = StifliFlexMcpUtils::getPrefixedTable('sflmcp_tools'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from sanitized helper. $results = $wpdb->get_results( $wpdb->prepare( "SELECT tool_name, token_estimate FROM {$tools_tbl} WHERE enabled = %d", 1 ), ARRAY_A ); foreach ($results as $row) { $name = isset($row['tool_name']) ? $row['tool_name'] : ''; if ('' === $name) { continue; } $enabled_tools[$name] = isset($row['token_estimate']) ? (int) $row['token_estimate'] : 0; } } // Filter tools by enabled status $filtered_tools = array(); foreach ($tools as $tool) { $name = StifliFlexMcpUtils::getArrayValue($tool, 'name', ''); if ('' === $name) { continue; } $allowed_by_integration = apply_filters('sflmcp_is_tool_enabled_for_integrations', true, $name, 'list', $tool); if (!$allowed_by_integration) { continue; } // Custom tools are already filtered by enabled=1 in getCustomTools() // So if the tool starts with 'custom_', it's already enabled $is_custom_tool = strpos($name, 'custom_') === 0; // Abilities are already filtered by enabled=1 in getImportedAbilities() // So if the tool starts with 'ability_', it's already enabled $is_ability = strpos($name, 'ability_') === 0; // If table doesn't exist, tool is in enabled list, or it's a custom tool/ability, include it if (!$table_exists || array_key_exists($name, $enabled_tools) || $is_custom_tool || $is_ability) { // Categoría if (in_array($name, array('search', 'fetch'), true)) { $tool['category'] = 'Core: OpenAI'; } elseif ($is_custom_tool) { $tool['category'] = 'Custom'; } elseif ($is_ability) { $tool['category'] = isset($tool['category']) ? $tool['category'] : 'Abilities'; } else { $tool['category'] = 'Core'; } // Intención y consentimiento $meta = $this->getIntentForTool($name); $tool['intent'] = $meta['intent']; // read | sensitive_read | write $tool['requires_confirmation'] = $meta['requires_confirmation']; // bool if ($table_exists && !$is_custom_tool && !$is_ability) { $tool['tokenEstimate'] = isset($enabled_tools[$name]) ? (int) $enabled_tools[$name] : StifliFlexMcpUtils::estimateToolTokenUsage($tool); } else { $tool['tokenEstimate'] = StifliFlexMcpUtils::estimateToolTokenUsage($tool); } $filtered_tools[] = $tool; } } return array_values($filtered_tools); } /** * Definición completa de tools usadas en dispatch (sin duplicados). */ public function getTools() { if (empty($this->tools)) { $tools = array( // Diagnóstico 'mcp_ping' => array( 'name' => 'mcp_ping', 'description' => 'Connectivity check with optional lightweight diagnostics. Returns the current GMT time, site info, and optional DNS/reachability details.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'diagnostics' => array('type' => 'boolean'), 'timeout_sec' => array('type' => 'integer'), ), 'required' => array(), ), ), // Posts (lectura) 'wp_get_posts' => array( 'name' => 'wp_get_posts', 'description' => 'List posts with filters. Optional enrichments: author, featured media, taxonomies, and pagination metadata.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_type' => array('type' => 'string'), 'post_status' => array('type' => 'string'), 'search' => array('type' => 'string'), 'limit' => array('type' => 'integer'), 'offset' => array('type' => 'integer'), 'paged' => array('type' => 'integer'), 'after' => array('type' => 'string'), 'before' => array('type' => 'string'), 'include_author' => array('type' => 'boolean'), 'include_featured_media' => array('type' => 'boolean'), 'include_taxonomies' => array('type' => 'boolean'), 'include_pagination' => array('type' => 'boolean'), ), 'required' => array(), ), ), 'wp_get_post' => array( 'name' => 'wp_get_post', 'description' => 'Get a single post by ID. Optional enrichments: author, featured media, and taxonomies.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'ID' => array('type' => 'integer'), 'include_author' => array('type' => 'boolean'), 'include_featured_media' => array('type' => 'boolean'), 'include_taxonomies' => array('type' => 'boolean'), ), 'required' => array('ID'), ), ), // Posts (mutación) 'wp_create_post' => array( 'name' => 'wp_create_post', 'description' => 'Create a post. Requires post_title. Optional: post_content, post_status, post_type, post_excerpt, post_author, featured_media (attachment ID), meta_input, post_category, tax_input, etc.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_title' => array('type' => 'string'), 'post_content' => array('type' => 'string'), 'post_status' => array('type' => 'string'), 'post_type' => array('type' => 'string'), 'post_excerpt' => array('type' => 'string'), 'post_author' => array('type' => 'integer'), 'featured_media' => array('type' => 'integer', 'description' => 'Attachment ID to use as featured image (thumbnail).'), 'meta_input' => array('type' => 'object'), 'post_name' => array('type' => 'string'), 'post_category'=> array('type' => 'array', 'items' => array('type' => 'integer')), 'tax_input' => array('type' => 'object'), ), 'required' => array('post_title'), ), ), 'wp_update_post' => array( 'name' => 'wp_update_post', 'description' => 'Update a post by ID. The "fields" object should use the standard parameters accepted by the WordPress wp_update_post() function. Supports taxonomy updates with fields.post_category / fields.tax_input (or top-level post_category / tax_input). Optional top-level featured_media (attachment ID) sets the featured image.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'ID' => array('type' => 'integer'), 'fields' => array('type' => 'object'), 'post_category'=> array('type' => 'array', 'items' => array('type' => 'integer')), 'tax_input' => array('type' => 'object'), 'meta_input' => array('type' => 'object'), 'featured_media' => array('type' => 'integer', 'description' => 'Attachment ID to set as featured image.'), ), 'required' => array('ID'), ), ), 'wp_set_featured_image' => array( 'name' => 'wp_set_featured_image', 'description' => 'Set or remove the featured image (post thumbnail) of a post. Pass attachment_id=0 to remove.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'attachment_id' => array('type' => 'integer', 'description' => 'Attachment post ID. Use 0 to clear the featured image.'), ), 'required' => array('post_id', 'attachment_id'), ), ), 'wp_delete_post' => array( 'name' => 'wp_delete_post', 'description' => 'Delete a post by ID.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'ID' => array('type' => 'integer'), 'force' => array('type' => 'boolean'), ), 'required' => array('ID'), ), ), // Comentarios 'wp_get_comments' => array( 'name' => 'wp_get_comments', 'description' => 'List comments. Supports post_id, status, search, limit, offset, paged, after, before, and optional post title or pagination metadata.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'status' => array('type' => 'string'), 'search' => array('type' => 'string'), 'limit' => array('type' => 'integer'), 'offset' => array('type' => 'integer'), 'paged' => array('type' => 'integer'), 'after' => array('type' => 'string'), 'before' => array('type' => 'string'), 'include_post_title' => array('type' => 'boolean'), 'include_pagination' => array('type' => 'boolean'), ), 'required' => array(), ), ), 'wp_create_comment' => array( 'name' => 'wp_create_comment', 'description' => 'Create a comment. Requires post_id and comment_content.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'comment_content' => array('type' => 'string'), 'comment_author' => array('type' => 'string'), 'comment_author_email' => array('type' => 'string'), 'comment_author_url' => array('type' => 'string'), 'comment_approved' => array('type' => 'integer'), ), 'required' => array('post_id','comment_content'), ), ), 'wp_update_comment' => array( 'name' => 'wp_update_comment', 'description' => 'Update a comment by comment_ID with fields object.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'comment_ID' => array('type' => 'integer'), 'fields' => array('type' => 'object'), ), 'required' => array('comment_ID'), ), ), 'wp_delete_comment' => array( 'name' => 'wp_delete_comment', 'description' => 'Delete a comment by comment_ID. Optional force flag.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'comment_ID' => array('type' => 'integer'), 'force' => array('type' => 'boolean'), ), 'required' => array('comment_ID'), ), ), // Usuarios 'wp_get_users' => array( 'name' => 'wp_get_users', 'description' => 'Retrieve users (fields: ID, user_login, display_name, roles). Optional enrichments: registration date, avatar URL, post counts, and pagination metadata.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'search' => array('type' => 'string'), 'role' => array('type' => 'string'), 'limit' => array('type' => 'integer'), 'offset' => array('type' => 'integer'), 'paged' => array('type' => 'integer'), 'include_registered_date' => array('type' => 'boolean'), 'include_avatar_url' => array('type' => 'boolean'), 'include_post_counts' => array('type' => 'boolean'), 'include_pagination' => array('type' => 'boolean'), ), 'required' => array(), ), ), // Removed for WordPress.org compliance: wp_create_user, wp_update_user // Media 'wp_get_media' => array( 'name' => 'wp_get_media', 'description' => 'List media attachments (limit, offset).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'limit' => array('type' => 'integer'), 'offset' => array('type' => 'integer'), ), 'required' => array(), ), ), 'wp_get_media_item' => array( 'name' => 'wp_get_media_item', 'description' => 'Get media item details by ID.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'ID' => array('type' => 'integer'), ), 'required' => array('ID'), ), ), 'wp_upload_image_from_url' => array( 'name' => 'wp_upload_image_from_url', 'description' => 'Download an image from a public URL and create a media attachment. Returns attachment ID and URL.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'url' => array('type' => 'string'), ), 'required' => array('url'), ), ), 'wp_upload_image' => array( 'name' => 'wp_upload_image', 'description' => 'Upload an image from base64 data and create a media attachment. Useful for AI-generated images. Accepts raw base64 or data URL (data:image/png;base64,...). Returns attachment ID and URL.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'image_data' => array('type' => 'string', 'description' => 'Base64 encoded image data. Accepts raw base64 string or data URL (e.g. data:image/png;base64,iVBOR...). Whitespace and newlines are stripped automatically.'), 'filename' => array('type' => 'string', 'description' => 'Filename with extension (e.g., "image.png"). If extension is missing or wrong, it will be corrected based on the actual image format.'), 'alt_text' => array('type' => 'string', 'description' => 'Alt text for the image'), 'title' => array('type' => 'string', 'description' => 'Title for the image'), 'post_id' => array('type' => 'integer', 'description' => 'Optional post ID to attach the image to'), ), 'required' => array('image_data', 'filename'), ), ), // Plugins / Temas 'wp_list_plugins' => array( 'name' => 'wp_list_plugins', 'description' => 'List installed plugins (returns array of {Name, Version}).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'search' => array('type' => 'string'), ), 'required' => array(), ), ), // Removed tools for WordPress.org compliance (Issues #5 & #6): // - wp_activate_plugin (activates plugins) // - wp_deactivate_plugin (deactivates plugins) // - wp_install_plugin (installs plugins) // - wp_install_theme (installs themes) // - wp_switch_theme (switches active theme) 'wp_get_themes' => array( 'name' => 'wp_get_themes', 'description' => 'List installed themes.', 'inputSchema' => array( 'type' => 'object', 'properties' => (object) array(), 'required' => array(), ), ), // Taxonomías y términos 'wp_get_taxonomies' => array( 'name' => 'wp_get_taxonomies', 'description' => 'List registered taxonomies.', 'inputSchema' => array( 'type' => 'object', 'properties' => (object) array(), 'required' => array(), ), ), 'wp_get_terms' => array( 'name' => 'wp_get_terms', 'description' => 'List terms for a taxonomy (taxonomy required).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'taxonomy' => array('type' => 'string'), ), 'required' => array('taxonomy'), ), ), 'wp_create_term' => array( 'name' => 'wp_create_term', 'description' => 'Create a term in any registered taxonomy (taxonomy and name required). Optional: slug, description, parent. Generalized replacement for wp_create_category and wp_create_tag.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'taxonomy' => array('type' => 'string'), 'name' => array('type' => 'string'), 'slug' => array('type' => 'string'), 'description' => array('type' => 'string'), 'parent' => array('type' => 'integer'), ), 'required' => array('taxonomy','name'), ), ), 'wp_update_term' => array( 'name' => 'wp_update_term', 'description' => 'Update a term in any registered taxonomy. Generalized replacement for wp_update_category and wp_update_tag.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), 'taxonomy' => array('type' => 'string'), 'name' => array('type' => 'string'), 'slug' => array('type' => 'string'), 'description' => array('type' => 'string'), 'parent' => array('type' => 'integer'), ), 'required' => array('term_id','taxonomy'), ), ), 'wp_delete_term' => array( 'name' => 'wp_delete_term', 'description' => 'Delete a term by term_id and taxonomy.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), 'taxonomy' => array('type' => 'string'), ), 'required' => array('term_id','taxonomy'), ), ), 'wp_get_term_meta' => array( 'name' => 'wp_get_term_meta', 'description' => 'Get term meta. Provide term_id and optional meta_key. Secrets-like values are redacted in the output.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), 'single' => array('type' => 'boolean'), ), 'required' => array('term_id'), ), ), 'wp_update_term_meta' => array( 'name' => 'wp_update_term_meta', 'description' => 'Update term meta (term_id, meta_key, meta_value).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), 'meta_value' => array('type' => 'string'), ), 'required' => array('term_id','meta_key','meta_value'), ), ), 'wp_delete_term_meta' => array( 'name' => 'wp_delete_term_meta', 'description' => 'Delete term meta (term_id, meta_key, meta_value optional).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), 'meta_value' => array('type' => 'string'), ), 'required' => array('term_id','meta_key'), ), ), // Menús de navegación 'wp_get_nav_menus' => array( 'name' => 'wp_get_nav_menus', 'description' => 'List all navigation menus.', 'inputSchema' => array( 'type' => 'object', 'properties' => (object) array(), 'required' => array(), ), ), 'wp_create_nav_menu' => array( 'name' => 'wp_create_nav_menu', 'description' => 'Create a new navigation menu. Requires menu_name.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'menu_name' => array('type' => 'string'), ), 'required' => array('menu_name'), ), ), 'wp_add_nav_menu_item' => array( 'name' => 'wp_add_nav_menu_item', 'description' => 'Add an item to a navigation menu. Requires menu_id, menu_item_title, menu_item_type (post_type, custom, taxonomy), menu_item_object (page, post, category, etc.), menu_item_object_id.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'menu_id' => array('type' => 'integer'), 'menu_item_title' => array('type' => 'string'), 'menu_item_type' => array('type' => 'string'), 'menu_item_object' => array('type' => 'string'), 'menu_item_object_id' => array('type' => 'integer'), 'menu_item_url' => array('type' => 'string'), 'menu_item_parent_id' => array('type' => 'integer'), ), 'required' => array('menu_id', 'menu_item_title', 'menu_item_type'), ), ), 'wp_update_nav_menu_item' => array( 'name' => 'wp_update_nav_menu_item', 'description' => 'Update a navigation menu item. Requires menu_id, menu_item_id, and fields object.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'menu_id' => array('type' => 'integer'), 'menu_item_id' => array('type' => 'integer'), 'fields' => array('type' => 'object'), ), 'required' => array('menu_id', 'menu_item_id'), ), ), 'wp_delete_nav_menu_item' => array( 'name' => 'wp_delete_nav_menu_item', 'description' => 'Delete a navigation menu item by menu_item_id.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'menu_item_id' => array('type' => 'integer'), ), 'required' => array('menu_item_id'), ), ), 'wp_delete_nav_menu' => array( 'name' => 'wp_delete_nav_menu', 'description' => 'Delete a navigation menu by menu_id.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'menu_id' => array('type' => 'integer'), ), 'required' => array('menu_id'), ), ), 'wp_reorder_menu_items' => array( 'name' => 'wp_reorder_menu_items', 'description' => 'Reorder items in a navigation menu in a single operation. Provide menu_id and items array of {item_id, menu_order, parent_id?}. Records previous order for one-click rollback.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'menu_id' => array('type' => 'integer'), 'items' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'item_id' => array('type' => 'integer'), 'menu_order' => array('type' => 'integer'), 'parent_id' => array('type' => 'integer'), ), 'required' => array('item_id','menu_order'), ), ), ), 'required' => array('menu_id','items'), ), ), // Opciones / Meta (lectura sensible + escritura) 'wp_get_option' => array( 'name' => 'wp_get_option', 'description' => 'Get a WordPress option value by name.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'option' => array('type' => 'string'), ), 'required' => array('option'), ), ), 'wp_get_plugin_settings' => array( 'name' => 'wp_get_plugin_settings', 'description' => 'Inspect plugin-related WordPress options safely by plugin slug/prefixes with recursive secret redaction. Requires manage_options.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'plugin_slug' => array('type' => 'string', 'description' => 'Plugin slug, e.g. stifli-flex-mcp, rank-math, yoast.'), 'option_prefixes' => array( 'type' => 'array', 'items' => array('type' => 'string'), 'description' => 'Optional extra option-name prefixes to include.', ), 'limit' => array('type' => 'integer', 'description' => 'Max options to return (default 100, hard cap 300).'), 'summary' => array('type' => 'boolean', 'description' => 'If true, return summary only.'), ), 'required' => array('plugin_slug'), ), ), 'wp_update_option' => array( 'name' => 'wp_update_option', 'description' => 'Update a WordPress option.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'option' => array('type' => 'string'), 'value' => array('type' => 'string'), ), 'required' => array('option','value'), ), ), // wp_delete_option intentionally removed for safety: cannot be reliably undone. 'wp_get_post_meta' => array( 'name' => 'wp_get_post_meta', 'description' => 'Get post meta (post_id, meta_key, single).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), 'single' => array('type' => 'boolean'), ), 'required' => array('post_id','meta_key'), ), ), 'wp_update_post_meta' => array( 'name' => 'wp_update_post_meta', 'description' => 'Update post meta (post_id, meta_key, meta_value).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), 'meta_value' => array('type' => 'string'), ), 'required' => array('post_id','meta_key','meta_value'), ), ), 'wp_delete_post_meta' => array( 'name' => 'wp_delete_post_meta', 'description' => 'Delete post meta (post_id, meta_key, meta_value optional).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), 'meta_value' => array('type' => 'string'), ), 'required' => array('post_id','meta_key'), ), ), // Búsqueda y red 'search' => array( 'name' => 'search', 'description' => 'Search posts with optional post type, author, category, tag, status, date, and sort filters plus paging and pagination metadata (q or query param).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'q' => array('type' => 'string'), 'limit' => array('type' => 'integer'), 'offset' => array('type' => 'integer'), 'paged' => array('type' => 'integer'), 'post_type' => array('type' => 'string'), 'post_status' => array('type' => 'string'), 'author' => array('type' => 'integer'), 'category' => array('type' => 'string'), 'tag' => array('type' => 'string'), 'orderby' => array('type' => 'string'), 'order' => array('type' => 'string'), 'after' => array('type' => 'string'), 'before' => array('type' => 'string'), 'include_pagination' => array('type' => 'boolean'), ), 'required' => array(), ), ), 'fetch' => array( 'name' => 'fetch', 'description' => 'Fetch a URL using WordPress HTTP API (url required, method optional). Optional controls: query params, custom request/response headers, timeout, redirects, HEAD-only mode, text extraction, and max body bytes.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'url' => array('type' => 'string'), 'method' => array('type' => 'string'), 'headers' => array('type' => 'object'), 'query_params' => array('type' => 'object'), 'body' => array('type' => 'string'), 'timeout_sec' => array('type' => 'integer'), 'max_redirects' => array('type' => 'integer'), 'head_only' => array('type' => 'boolean'), 'include_headers' => array('type' => 'boolean'), 'include_request_headers' => array('type' => 'boolean'), 'extract_text' => array('type' => 'boolean'), 'max_bytes' => array('type' => 'integer'), 'accept' => array('type' => 'string'), 'content_type' => array('type' => 'string'), 'user_agent' => array('type' => 'string'), ), 'required' => array('url'), ), ), // Rank Math SEO 'wp_rm_get_head' => array( 'name' => 'wp_rm_get_head', 'description' => 'Get rendered SEO head HTML for a URL using Rank Math endpoint. Requires Rank Math Headless CMS Support enabled.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'url' => array('type' => 'string'), ), 'required' => array('url'), ), ), 'wp_rm_get_post_seo' => array( 'name' => 'wp_rm_get_post_seo', 'description' => 'Get Rank Math SEO post meta fields for a post ID.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), ), 'required' => array('post_id'), ), ), 'wp_rm_update_post_seo' => array( 'name' => 'wp_rm_update_post_seo', 'description' => 'Update Rank Math SEO post meta fields for a post ID.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'title' => array('type' => 'string'), 'description' => array('type' => 'string'), 'focus_keyword' => array('type' => 'string'), 'canonical_url' => array('type' => 'string'), 'facebook_title' => array('type' => 'string'), 'facebook_description' => array('type' => 'string'), 'facebook_image' => array('type' => 'string'), 'twitter_title' => array('type' => 'string'), 'twitter_description' => array('type' => 'string'), 'twitter_image' => array('type' => 'string'), ), 'required' => array('post_id'), ), ), // Yoast SEO 'yoast_get_meta' => array( 'name' => 'yoast_get_meta', 'description' => 'Get Yoast SEO meta fields for a post (title, description, focus keyword, canonical, robots, OG, Twitter). Requires Yoast SEO plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer', 'description' => 'Post ID to read Yoast meta from'), ), 'required' => array('post_id'), ), ), 'yoast_set_meta' => array( 'name' => 'yoast_set_meta', 'description' => 'Set Yoast SEO meta fields for a post. Accepts title, description, focus_keyword, canonical, noindex, nofollow, facebook_title, facebook_description, facebook_image, twitter_title, twitter_description, twitter_image. Requires Yoast SEO plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'title' => array('type' => 'string'), 'description' => array('type' => 'string'), 'focus_keyword' => array('type' => 'string'), 'canonical' => array('type' => 'string'), 'noindex' => array('type' => 'boolean'), 'nofollow' => array('type' => 'boolean'), 'facebook_title' => array('type' => 'string'), 'facebook_description' => array('type' => 'string'), 'facebook_image' => array('type' => 'string'), 'twitter_title' => array('type' => 'string'), 'twitter_description' => array('type' => 'string'), 'twitter_image' => array('type' => 'string'), ), 'required' => array('post_id'), ), ), 'yoast_reindex' => array( 'name' => 'yoast_reindex', 'description' => 'Clear Yoast SEO indexables cache for a post or for all posts (site-wide). Requires Yoast SEO plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer', 'description' => 'Post ID to reindex, or omit for site-wide cache clear'), ), 'required' => array(), ), ), // Advanced Custom Fields (ACF) 'acf_get_field_groups' => array( 'name' => 'acf_get_field_groups', 'description' => 'List all ACF field groups with their keys, titles and location rules. Requires Advanced Custom Fields plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => (object) array(), 'required' => array(), ), ), 'acf_get_fields' => array( 'name' => 'acf_get_fields', 'description' => 'Get ACF field values for a post. Returns field keys, names, types and current values. Requires ACF plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer', 'description' => 'Post ID to read ACF fields from'), ), 'required' => array('post_id'), ), ), 'acf_update_field' => array( 'name' => 'acf_update_field', 'description' => 'Update an ACF field value for a post by field name or key. Requires ACF plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), 'field_name' => array('type' => 'string', 'description' => 'ACF field name or key'), 'value' => array('description' => 'New field value (string, number, boolean, array)'), ), 'required' => array('post_id', 'field_name', 'value'), ), ), // WPForms 'wpforms_list_forms' => array( 'name' => 'wpforms_list_forms', 'description' => 'List all WPForms forms (ID, title, status, created date). Requires WPForms plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'limit' => array('type' => 'integer', 'description' => 'Max forms to return (default 50)'), 'offset' => array('type' => 'integer'), ), 'required' => array(), ), ), 'wpforms_get_entries' => array( 'name' => 'wpforms_get_entries', 'description' => 'Get form entries for a WPForms form. Returns entry ID, date, fields and status. Requires WPForms plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'form_id' => array('type' => 'integer'), 'limit' => array('type' => 'integer', 'description' => 'Max entries (default 20)'), 'offset' => array('type' => 'integer'), 'status' => array('type' => 'string', 'description' => 'Entry status: active, spam, trash or empty for all'), ), 'required' => array('form_id'), ), ), // Gravity Forms 'gf_list_forms' => array( 'name' => 'gf_list_forms', 'description' => 'List all Gravity Forms (ID, title, description, entry count). Requires Gravity Forms plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'active' => array('type' => 'boolean', 'description' => 'Filter by active status'), ), 'required' => array(), ), ), 'gf_get_entries' => array( 'name' => 'gf_get_entries', 'description' => 'Get entries for a Gravity Forms form. Requires Gravity Forms plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'form_id' => array('type' => 'integer'), 'page_size' => array('type' => 'integer', 'description' => 'Entries per page (default 20)'), 'offset' => array('type' => 'integer'), 'status' => array('type' => 'string', 'description' => 'active, spam, trash or empty for all'), 'search_value' => array('type' => 'string'), 'field_id' => array('type' => 'string', 'description' => 'Field ID for search filter'), ), 'required' => array('form_id'), ), ), 'gf_update_entry' => array( 'name' => 'gf_update_entry', 'description' => 'Update a Gravity Forms entry (status, is_read, is_starred, or field values). Requires Gravity Forms plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'entry_id' => array('type' => 'integer'), 'status' => array('type' => 'string', 'description' => 'active, spam, trash'), 'is_read' => array('type' => 'boolean'), 'is_starred' => array('type' => 'boolean'), 'field_values' => array('type' => 'object', 'description' => 'Object of field_id => value to update'), ), 'required' => array('entry_id'), ), ), // Forminator 'forminator_list_forms' => array( 'name' => 'forminator_list_forms', 'description' => 'List all Forminator forms (custom forms, polls, quizzes). Requires Forminator plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'type' => array('type' => 'string', 'description' => 'Form type: custom-forms (default), poll, quiz'), 'limit' => array('type' => 'integer'), ), 'required' => array(), ), ), 'forminator_get_entries' => array( 'name' => 'forminator_get_entries', 'description' => 'Get submission entries for a Forminator form. Requires Forminator plugin active.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'form_id' => array('type' => 'integer'), 'per_page' => array('type' => 'integer', 'description' => 'Entries per page (default 20)'), 'page' => array('type' => 'integer'), ), 'required' => array('form_id'), ), ), // AI Image Generation 'wp_generate_image' => array( 'name' => 'wp_generate_image', 'description' => 'Generate an image using AI and save it as a WordPress media attachment. Uses the configured AI provider (OpenAI/Gemini). Returns attachment ID, URL and medium-size URL. Supports size (square, landscape, portrait or aspect ratio like 16:9) and quality (low, medium, high for OpenAI).', 'execution' => array( 'taskSupport' => 'optional', ), 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'prompt' => array('type' => 'string', 'description' => 'Detailed description of the image to generate'), 'size' => array('type' => 'string', 'description' => 'Image size: square (default), landscape, portrait, or aspect ratio like 16:9 for Gemini'), 'quality' => array('type' => 'string', 'description' => 'Quality for OpenAI: low, medium (default), high'), 'alt_text' => array('type' => 'string', 'description' => 'Alt text for the image attachment'), 'title' => array('type' => 'string', 'description' => 'Title for the image attachment'), 'post_id' => array('type' => 'integer', 'description' => 'Optional post ID to attach the image to'), ), 'required' => array('prompt'), ), ), // AI Video Generation 'wp_generate_video' => array( 'name' => 'wp_generate_video', 'description' => 'Generate a video using AI (Google Veo or OpenAI Sora) and save it as a WordPress media attachment. Video generation is asynchronous and may take 1-5 minutes. Returns attachment ID, URL, duration, and provider info. Configure defaults in Multimedia Settings.', 'execution' => array( 'taskSupport' => 'optional', ), 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'prompt' => array('type' => 'string', 'description' => 'Detailed description of the video to generate. Be specific about scene, camera movement, lighting, style.'), 'image_url' => array('type' => 'string', 'description' => 'Optional source/start-frame image. Can be a URL or a WordPress attachment ID. Veo uses it as the first frame; Sora uses it as visual reference. Supported: JPEG, PNG.'), 'image_end_url' => array('type' => 'string', 'description' => 'Optional end-frame image (Veo only). When both image_url and image_end_url are provided, Veo interpolates between the two frames. Can be a URL or attachment ID.'), 'duration' => array('type' => 'string', 'description' => 'Video duration in seconds: 5, 6, 8 (Veo), or 4, 8, 12 (Sora). Default from settings.'), 'aspect_ratio' => array('type' => 'string', 'description' => 'Aspect ratio: 16:9 (landscape), 9:16 (portrait/reels), 1:1 (square/Veo only). Default from settings.'), 'title' => array('type' => 'string', 'description' => 'Title for the video attachment in the Media Library'), 'post_id' => array('type' => 'integer', 'description' => 'Optional post ID to attach the video to'), ), 'required' => array('prompt'), ), ), // Pages 'wp_get_pages' => array( 'name' => 'wp_get_pages', 'description' => 'List pages with filters (post_status, search, limit, offset, orderby, order).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_status' => array('type' => 'string'), 'search' => array('type' => 'string'), 'limit' => array('type' => 'integer'), 'offset' => array('type' => 'integer'), 'orderby' => array('type' => 'string'), 'order' => array('type' => 'string'), ), 'required' => array(), ), ), 'wp_create_page' => array( 'name' => 'wp_create_page', 'description' => 'Create a new page (post_title, post_content, post_status, post_author, post_parent, menu_order, meta_input).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_title' => array('type' => 'string'), 'post_content' => array('type' => 'string'), 'post_status' => array('type' => 'string'), 'post_author' => array('type' => 'integer'), 'post_parent' => array('type' => 'integer'), 'menu_order' => array('type' => 'integer'), 'meta_input' => array('type' => 'object'), ), 'required' => array('post_title'), ), ), 'wp_update_page' => array( 'name' => 'wp_update_page', 'description' => 'Update a page by ID (post_title, post_content, post_status, post_author, post_parent, menu_order, meta_input).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'ID' => array('type' => 'integer'), 'post_title' => array('type' => 'string'), 'post_content' => array('type' => 'string'), 'post_status' => array('type' => 'string'), 'post_author' => array('type' => 'integer'), 'post_parent' => array('type' => 'integer'), 'menu_order' => array('type' => 'integer'), 'meta_input' => array('type' => 'object'), ), 'required' => array('ID'), ), ), 'wp_delete_page' => array( 'name' => 'wp_delete_page', 'description' => 'Delete a page by ID. Pass force=true to skip trash.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'ID' => array('type' => 'integer'), 'force' => array('type' => 'boolean'), ), 'required' => array('ID'), ), ), // Removed for WordPress.org compliance: wp_delete_user // User Meta 'wp_get_user_meta' => array( 'name' => 'wp_get_user_meta', 'description' => 'Get user meta by user_id and optional meta_key. Returns all meta if key not specified.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'user_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), ), 'required' => array('user_id'), ), ), 'wp_update_user_meta' => array( 'name' => 'wp_update_user_meta', 'description' => 'Update user meta by user_id and meta_key with meta_value.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'user_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), 'meta_value' => array('type' => 'string'), ), 'required' => array('user_id', 'meta_key', 'meta_value'), ), ), 'wp_delete_user_meta' => array( 'name' => 'wp_delete_user_meta', 'description' => 'Delete user meta by user_id and meta_key.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'user_id' => array('type' => 'integer'), 'meta_key' => array('type' => 'string'), ), 'required' => array('user_id', 'meta_key'), ), ), // Categories 'wp_get_categories' => array( 'name' => 'wp_get_categories', 'description' => 'List categories (hide_empty, search, limit).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'hide_empty' => array('type' => 'boolean'), 'search' => array('type' => 'string'), 'limit' => array('type' => 'integer'), ), 'required' => array(), ), ), 'wp_create_category' => array( 'name' => 'wp_create_category', 'description' => 'Create a category (name required, slug, parent, description optional).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'name' => array('type' => 'string'), 'slug' => array('type' => 'string'), 'parent' => array('type' => 'integer'), 'description' => array('type' => 'string'), ), 'required' => array('name'), ), ), 'wp_update_category' => array( 'name' => 'wp_update_category', 'description' => 'Update a category by term_id (name, slug, parent, description).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), 'name' => array('type' => 'string'), 'slug' => array('type' => 'string'), 'parent' => array('type' => 'integer'), 'description' => array('type' => 'string'), ), 'required' => array('term_id'), ), ), 'wp_delete_category' => array( 'name' => 'wp_delete_category', 'description' => 'Delete a category by term_id.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), ), 'required' => array('term_id'), ), ), // Tags 'wp_get_tags' => array( 'name' => 'wp_get_tags', 'description' => 'List tags (hide_empty, search, limit).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'hide_empty' => array('type' => 'boolean'), 'search' => array('type' => 'string'), 'limit' => array('type' => 'integer'), ), 'required' => array(), ), ), 'wp_create_tag' => array( 'name' => 'wp_create_tag', 'description' => 'Create a tag (name required, slug, description optional).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'name' => array('type' => 'string'), 'slug' => array('type' => 'string'), 'description' => array('type' => 'string'), ), 'required' => array('name'), ), ), 'wp_update_tag' => array( 'name' => 'wp_update_tag', 'description' => 'Update a tag by term_id (name, slug, description).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), 'name' => array('type' => 'string'), 'slug' => array('type' => 'string'), 'description' => array('type' => 'string'), ), 'required' => array('term_id'), ), ), 'wp_delete_tag' => array( 'name' => 'wp_delete_tag', 'description' => 'Delete a tag by term_id.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'term_id' => array('type' => 'integer'), ), 'required' => array('term_id'), ), ), // Media 'wp_update_media_item' => array( 'name' => 'wp_update_media_item', 'description' => 'Update media item metadata (ID required, post_title, post_content, post_excerpt).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'ID' => array('type' => 'integer'), 'post_title' => array('type' => 'string'), 'post_content' => array('type' => 'string'), 'post_excerpt' => array('type' => 'string'), ), 'required' => array('ID'), ), ), 'wp_delete_media_item' => array( 'name' => 'wp_delete_media_item', 'description' => 'Delete a media item by ID. Pass force=true to delete permanently.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'ID' => array('type' => 'integer'), 'force' => array('type' => 'boolean'), ), 'required' => array('ID'), ), ), // Menus 'wp_get_menus' => array( 'name' => 'wp_get_menus', 'description' => 'List all navigation menus (alias for wp_get_nav_menus).', 'inputSchema' => array( 'type' => 'object', 'properties' => (object) array(), 'required' => array(), ), ), 'wp_get_menu' => array( 'name' => 'wp_get_menu', 'description' => 'Get a specific menu with its items (menu_id or menu_location required).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'menu_id' => array('type' => 'integer'), 'menu_location' => array('type' => 'string'), ), 'required' => array(), ), ), // Settings 'wp_get_settings' => array( 'name' => 'wp_get_settings', 'description' => 'Get WordPress settings. Optionally pass "keys" array to get specific options.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'keys' => array('type' => 'array', 'items' => array('type' => 'string')), ), 'required' => array(), ), ), 'wp_update_settings' => array( 'name' => 'wp_update_settings', 'description' => 'Update WordPress settings. Pass "settings" object with key-value pairs.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'settings' => array('type' => 'object'), ), 'required' => array('settings'), ), ), // Post Revisions 'wp_get_post_revisions' => array( 'name' => 'wp_get_post_revisions', 'description' => 'Get revisions for a post by post_id.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_id' => array('type' => 'integer'), ), 'required' => array('post_id'), ), ), 'wp_restore_post_revision' => array( 'name' => 'wp_restore_post_revision', 'description' => 'Restore a post to a specific revision by revision_id.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'revision_id' => array('type' => 'integer'), ), 'required' => array('revision_id'), ), ), // Custom Post Types 'wp_get_post_types' => array( 'name' => 'wp_get_post_types', 'description' => 'Get all registered post types with their details (labels, capabilities, public status, etc).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'public_only' => array('type' => 'boolean'), ), 'required' => array(), ), ), // Site Health 'wp_get_site_health' => array( 'name' => 'wp_get_site_health', 'description' => 'Run a WordPress site audit with selectable depth. Level 0: basic, fast checks. Level 1: medium, all direct Site Health checks. Level 2: deep, adds async checks and storage scan.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'level' => array( 'type' => 'integer', 'description' => 'Audit depth. 0 = basic and fast. 1 = medium with all direct Site Health checks. 2 = deep with async Site Health checks and directory sizes. Default: 0.', 'minimum' => 0, 'maximum' => 2, ), ), 'required' => array(), ), ), // Changelog / Audit Log 'mcp_get_changelog' => array( 'name' => 'mcp_get_changelog', 'description' => 'Get the changelog/audit log of MCP tool operations. Supports filtering by tool, operation type, object type, date range, and rollback status. Returns paginated results with total count.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'tool_name' => array('type' => 'string', 'description' => 'Filter by tool name (e.g. wp_update_post).'), 'operation_type' => array('type' => 'string', 'description' => 'Filter by operation: create, update, delete, file_create, file_delete, unknown.'), 'object_type' => array('type' => 'string', 'description' => 'Filter by object type: post, page, comment, user, term, option, media, product, order, coupon, etc.'), 'date_from' => array('type' => 'string', 'description' => 'Start date filter (YYYY-MM-DD).'), 'date_to' => array('type' => 'string', 'description' => 'End date filter (YYYY-MM-DD).'), 'rolled_back' => array('type' => 'integer', 'description' => '0=active only, 1=rolled-back only. Omit for all.'), 'page' => array('type' => 'integer', 'description' => 'Page number (default 1).'), 'per_page' => array('type' => 'integer', 'description' => 'Results per page (default 25, max 100).'), ), 'required' => array(), ), ), 'mcp_get_change_detail' => array( 'name' => 'mcp_get_change_detail', 'description' => 'Get full detail of a single changelog entry including before/after state snapshots and arguments used.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'id' => array('type' => 'integer', 'description' => 'Changelog entry ID.'), ), 'required' => array('id'), ), ), 'mcp_rollback_change' => array( 'name' => 'mcp_rollback_change', 'description' => 'Rollback a specific changelog entry, reverting the change to the before-state. Only works on entries that have not already been rolled back.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'id' => array('type' => 'integer', 'description' => 'Changelog entry ID to rollback.'), ), 'required' => array('id'), ), ), 'mcp_redo_change' => array( 'name' => 'mcp_redo_change', 'description' => 'Redo a previously rolled back changelog entry, re-applying the after-state. Only works on entries that have been rolled back.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'id' => array('type' => 'integer', 'description' => 'Changelog entry ID to redo.'), ), 'required' => array('id'), ), ), 'mcp_rollback_session' => array( 'name' => 'mcp_rollback_session', 'description' => 'Rollback all changes made in a specific session (by session_id), in reverse chronological order (LIFO). Returns count of changes rolled back.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'session_id' => array('type' => 'string', 'description' => 'Session ID to rollback all changes for.'), ), 'required' => array('session_id'), ), ), ); // Merge Snippets tools if the optional module is available. if ( $this->maybeLoadSnippetsModule() && class_exists( 'StifliFlexMcp_Snippets' ) ) { $tools = array_merge( $tools, StifliFlexMcp_Snippets::getTools() ); } // Merge The Events Calendar tools only when the plugin integration is enabled. if ( $this->maybeLoadTecIntegrationModule() && class_exists( 'StifliFlexMcp_TheEventsCalendar' ) ) { $tools = array_merge( $tools, StifliFlexMcp_TheEventsCalendar::getTools() ); } // Merge WooCommerce tools if available // Lazy load modules ensures compatibility with all load orders if ( $this->maybeLoadWooCommerceModules() ) { if ( class_exists( 'StifliFlexMcp_WC_Products' ) ) { $tools = array_merge( $tools, StifliFlexMcp_WC_Products::getTools() ); } if ( class_exists( 'StifliFlexMcp_WC_Orders' ) ) { $tools = array_merge( $tools, StifliFlexMcp_WC_Orders::getTools() ); } if ( class_exists( 'StifliFlexMcp_WC_Customers' ) ) { $tools = array_merge( $tools, StifliFlexMcp_WC_Customers::getTools() ); } if ( class_exists( 'StifliFlexMcp_WC_Coupons' ) ) { $tools = array_merge( $tools, StifliFlexMcp_WC_Coupons::getTools() ); } if ( class_exists( 'StifliFlexMcp_WC_System' ) ) { $tools = array_merge( $tools, StifliFlexMcp_WC_System::getTools() ); } } $this->tools = $tools; } // Add Custom Tools $custom_tools = $this->getCustomTools(); if (!empty($custom_tools)) { foreach ($custom_tools as $tool) { // Ensure proper structure if ( ! is_array( $tool ) || ! isset( $tool['name'] ) || ! isset( $tool['inputSchema'] ) ) continue; $tool_name = $tool['name']; if ( ! is_string( $tool_name ) || '' === $tool_name ) continue; $this->tools[ $tool_name ] = $tool; } } // Add WordPress Abilities (WordPress 6.9+) $abilities = $this->getImportedAbilities(); if (!empty($abilities)) { foreach ($abilities as $tool) { if ( ! is_array( $tool ) || ! isset( $tool['name'] ) || ! isset( $tool['inputSchema'] ) ) continue; $tool_name = $tool['name']; if ( ! is_string( $tool_name ) || '' === $tool_name ) continue; $this->tools[ $tool_name ] = $tool; } } return $this->tools; } private function maybeLoadTecIntegrationModule() { if ( class_exists( 'StifliFlexMcp_TheEventsCalendar' ) ) { return true; } if ( ! $this->isTecIntegrationEnabled() ) { return false; } if ( ! class_exists( 'Tribe__Events__Main' ) ) { return false; } if ( ! post_type_exists( 'tribe_events' ) || ! post_type_exists( 'tribe_venue' ) || ! post_type_exists( 'tribe_organizer' ) ) { return false; } $module_file = dirname( __FILE__ ) . '/integrations/class-the-events-calendar.php'; if ( ! file_exists( $module_file ) ) { return false; } require_once $module_file; return class_exists( 'StifliFlexMcp_TheEventsCalendar' ); } private function maybeLoadOptionalModuleForTool( $tool ) { if ( 0 === strpos( (string) $tool, 'wp_tec_' ) ) { return $this->maybeLoadTecIntegrationModule(); } return false; } private function isTecIntegrationEnabled() { $state = $this->getPluginIntegrationState(); return in_array( 'the_events_calendar', $state['enabled_groups'], true ); } private function getPluginIntegrationState() { global $wpdb; $raw = array(); $profiles_table = StifliFlexMcpUtils::getPrefixedTable( 'sflmcp_profiles', false ); $profiles_like = $wpdb->esc_like( $profiles_table ); $profiles_exist = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $profiles_like ) ) === $profiles_table; if ( $profiles_exist ) { $active_profile_id = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$profiles_table} WHERE is_active = %d LIMIT 1", 1 ) ); if ( $active_profile_id ) { $profile_raw = get_option( 'sflmcp_plugin_integrations_state_profile_' . intval( $active_profile_id ), null ); if ( is_array( $profile_raw ) ) { $raw = $profile_raw; } } } if ( empty( $raw ) ) { $raw = get_option( 'sflmcp_plugin_integrations_state', array() ); } return array( 'enabled_groups' => isset( $raw['enabled_groups'] ) && is_array( $raw['enabled_groups'] ) ? array_values( array_map( 'sanitize_key', $raw['enabled_groups'] ) ) : array(), 'disabled_tools' => isset( $raw['disabled_tools'] ) && is_array( $raw['disabled_tools'] ) ? array_values( array_map( 'sanitize_key', $raw['disabled_tools'] ) ) : array(), ); } /** * Get defined custom tools from database */ private function getCustomTools() { global $wpdb; $table = $wpdb->prefix . 'sflmcp_custom_tools'; // Check if table exists first (during updates it might not exist yet) $like = $wpdb->esc_like($table); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- schema check requires direct query. if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $like ) ) !== $table ) { return array(); } $tools = array(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- cache disabled for fresh tools, table name is safe. $results = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM `$table` WHERE enabled = %d", 1 ) ); if (!$results) return array(); foreach ($results as $row) { $schema = json_decode($row->arguments, true); if (json_last_error() !== JSON_ERROR_NONE) { $schema = array('type' => 'object', 'properties' => (object) array(), 'required' => array()); } $tools[] = array( 'name' => $row->tool_name, 'description' => $row->tool_description, 'inputSchema' => $schema, 'method' => $row->method, 'endpoint' => $row->endpoint, 'headers' => $row->headers, 'category' => 'Custom', 'intent' => 'sensitive_read', // Default safe intent 'requires_confirmation' => true, // Always require confirmation for external calls ); } return $tools; } /** * Get imported WordPress Abilities from database (WordPress 6.9+) * These are abilities from other plugins that have been imported via the admin UI. */ private function getImportedAbilities() { global $wpdb; $table = StifliFlexMcpUtils::getPrefixedTable('sflmcp_abilities', false); // Check if table exists first $like = $wpdb->esc_like($table); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- schema check. if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $like)) !== $table) { return array(); } $tools = array(); $table_safe = StifliFlexMcpUtils::getPrefixedTable('sflmcp_abilities'); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from sanitized helper. $results = $wpdb->get_results($wpdb->prepare("SELECT * FROM {$table_safe} WHERE enabled = %d", 1)); if (!$results) { return array(); } foreach ($results as $row) { $input_schema = json_decode($row->input_schema, true); if (json_last_error() !== JSON_ERROR_NONE || !is_array($input_schema)) { $input_schema = array('type' => 'object', 'properties' => (object) array(), 'required' => array()); } $input_schema = StifliFlexMcpUtils::normalizeToolInputSchema( $input_schema ); // Convert ability name to tool name: "allsi/search-image" -> "ability_allsi_search_image" $tool_name = 'ability_' . str_replace(array('/', '-'), '_', $row->ability_name); $tools[] = array( 'name' => $tool_name, 'description' => $row->ability_description ?: $row->ability_label, 'inputSchema' => $input_schema, 'category' => 'Abilities - ' . $row->ability_category, 'intent' => 'sensitive_read', // Abilities may have side effects 'requires_confirmation' => true, // Store original ability name for execution '_ability_name' => $row->ability_name, '_is_ability' => true, ); } return $tools; } /** * Exportar herramientas como funciones OpenAI/ChatGPT (+metadata) */ public function getOpenAIFunctions() { $tools = $this->getToolsList(); $funcs = array(); foreach ($tools as $t) { $f = array( 'name' => $t['name'], 'description' => isset($t['description']) ? $t['description'] : '', 'parameters' => array( 'type' => 'object', 'properties' => (isset($t['inputSchema']) ? $t['inputSchema']['properties'] : new stdClass()), 'required' => (isset($t['inputSchema']) && isset($t['inputSchema']['required']) ? $t['inputSchema']['required'] : array()), ), // NUEVO: metadata para controlar confirmaciones en el cliente 'metadata' => array( 'intent' => $t['intent'] ?? 'read', 'requires_confirmation' => $t['requires_confirmation'] ?? false, 'category' => $t['category'] ?? 'Core', ), ); $funcs[] = $f; } return $funcs; } /** * Validación básica de argumentos */ public function validateArgumentsSchema($schema, $args, & $err = '') { $err = ''; if (!is_array($schema) || empty($schema['type']) || $schema['type'] !== 'object') { return true; // sin esquema } $props = isset($schema['properties']) ? $schema['properties'] : array(); // required if (!empty($schema['required']) && is_array($schema['required'])) { foreach ($schema['required'] as $rk) { if (!isset($args[$rk])) { $err = 'Missing required parameter: ' . $rk; return false; } } } // tipos básicos foreach ($props as $k => $p) { if (!isset($args[$k])) continue; $val = $args[$k]; if (!isset($p['type'])) continue; $type = $p['type']; switch ($type) { case 'string': if (!is_string($val)) { $err = "Parameter $k must be a string"; return false; } break; case 'integer': if (!is_int($val) && !(is_string($val) && ctype_digit($val))) { $err = "Parameter $k must be an integer"; return false; } break; case 'boolean': if (!is_bool($val) && !in_array($val, array(true,false,0,1,'0','1'), true)) { $err = "Parameter $k must be boolean"; return false; } break; case 'object': if (!is_array($val) && !is_object($val)) { $err = "Parameter $k must be an object"; return false; } break; case 'array': if (!is_array($val)) { $err = "Parameter $k must be an array"; return false; } break; default: break; } } return true; } /** * Capacidades WP para tools de escritura (lecturas sensibles se chequean en dispatch). */ public function getToolCapability($tool) { $map = array( // posts 'wp_create_post' => 'edit_posts', 'wp_update_post' => 'edit_posts', 'wp_delete_post' => 'delete_posts', 'wp_set_featured_image' => 'edit_posts', // pages 'wp_create_page' => 'edit_pages', 'wp_update_page' => 'edit_pages', 'wp_delete_page' => 'delete_pages', // comments 'wp_create_comment' => 'moderate_comments', 'wp_update_comment' => 'moderate_comments', 'wp_delete_comment' => 'moderate_comments', // Removed for WordPress.org compliance: wp_create_user, wp_update_user, wp_delete_user // user meta 'wp_get_user_meta' => 'list_users', 'wp_update_user_meta' => 'edit_users', 'wp_delete_user_meta' => 'edit_users', // post revisions 'wp_restore_post_revision' => 'edit_posts', // site health 'wp_get_site_health' => 'manage_options', // categories 'wp_create_category' => 'manage_categories', 'wp_update_category' => 'manage_categories', 'wp_delete_category' => 'manage_categories', // tags 'wp_create_tag' => 'manage_categories', 'wp_update_tag' => 'manage_categories', 'wp_delete_tag' => 'manage_categories', // media 'wp_upload_image_from_url' => 'upload_files', 'wp_upload_image' => 'upload_files', 'wp_generate_image' => 'upload_files', 'wp_generate_video' => 'upload_files', 'wp_update_media_item' => 'upload_files', 'wp_delete_media_item' => 'delete_posts', // plugins/themes 'wp_activate_plugin' => 'activate_plugins', 'wp_deactivate_plugin' => 'activate_plugins', 'wp_install_plugin' => 'install_plugins', 'wp_install_theme' => 'install_themes', 'wp_switch_theme' => 'switch_themes', // options/meta/settings 'wp_update_option' => 'manage_options', 'wp_get_plugin_settings' => 'manage_options', 'wp_update_post_meta' => 'manage_options', 'wp_delete_post_meta' => 'manage_options', 'wp_get_settings' => 'manage_options', 'wp_update_settings' => 'manage_options', // terms 'wp_create_term' => 'manage_categories', 'wp_update_term' => 'manage_categories', 'wp_delete_term' => 'manage_categories', // term meta 'wp_get_term_meta' => 'manage_categories', 'wp_update_term_meta' => 'manage_categories', 'wp_delete_term_meta' => 'manage_categories', // menús 'wp_create_nav_menu' => 'edit_theme_options', 'wp_add_nav_menu_item' => 'edit_theme_options', 'wp_update_nav_menu_item' => 'edit_theme_options', 'wp_delete_nav_menu_item' => 'edit_theme_options', 'wp_delete_nav_menu' => 'edit_theme_options', 'wp_reorder_menu_items' => 'edit_theme_options', // Changelog 'mcp_get_changelog' => 'manage_options', 'mcp_get_change_detail' => 'manage_options', 'mcp_rollback_change' => 'manage_options', 'mcp_redo_change' => 'manage_options', 'mcp_rollback_session' => 'manage_options', // Rank Math 'wp_rm_get_head' => 'edit_posts', 'wp_rm_get_post_seo' => 'edit_posts', 'wp_rm_update_post_seo' => 'edit_posts', // Yoast SEO 'yoast_get_meta' => 'edit_posts', 'yoast_set_meta' => 'edit_posts', 'yoast_reindex' => 'manage_options', // ACF 'acf_get_field_groups' => 'edit_posts', 'acf_get_fields' => 'edit_posts', 'acf_update_field' => 'edit_posts', // WPForms 'wpforms_list_forms' => 'manage_options', 'wpforms_get_entries' => 'manage_options', // Gravity Forms 'gf_list_forms' => 'manage_options', 'gf_get_entries' => 'manage_options', 'gf_update_entry' => 'manage_options', // Forminator 'forminator_list_forms' => 'manage_options', 'forminator_get_entries' => 'manage_options', ); // Merge WooCommerce capabilities if available if ( $this->maybeLoadWooCommerceModules() ) { if ( class_exists( 'StifliFlexMcp_WC_Products' ) ) { $map = array_merge( $map, StifliFlexMcp_WC_Products::getCapabilities() ); } if ( class_exists( 'StifliFlexMcp_WC_Orders' ) ) { $map = array_merge( $map, StifliFlexMcp_WC_Orders::getCapabilities() ); } if ( class_exists( 'StifliFlexMcp_WC_Customers' ) ) { $map = array_merge( $map, StifliFlexMcp_WC_Customers::getCapabilities() ); } if ( class_exists( 'StifliFlexMcp_WC_Coupons' ) ) { $map = array_merge( $map, StifliFlexMcp_WC_Coupons::getCapabilities() ); } if ( class_exists( 'StifliFlexMcp_WC_System' ) ) { $map = array_merge( $map, StifliFlexMcp_WC_System::getCapabilities() ); } } // Merge Snippets capabilities if available if ( $this->maybeLoadSnippetsModule() && class_exists( 'StifliFlexMcp_Snippets' ) ) { $map = array_merge( $map, StifliFlexMcp_Snippets::getCapabilities() ); } if ( $this->maybeLoadTecIntegrationModule() && class_exists( 'StifliFlexMcp_TheEventsCalendar' ) ) { $map = array_merge( $map, StifliFlexMcp_TheEventsCalendar::getCapabilities() ); } return isset($map[$tool]) ? $map[$tool] : null; } public function dispatchTool($tool, $args, $id = null) { $tool = is_string( $tool ) ? $tool : ( is_scalar( $tool ) ? (string) $tool : '' ); $r = array('jsonrpc' => '2.0', 'id' => $id); $utils = 'StifliFlexMcpUtils'; $frame = class_exists('StifliFlexMcpFrame') ? StifliFlexMcpFrame::_() : null; $addResultText = function(array &$r, string $text) { if (!isset($r['result']['content'])) { $r['result']['content'] = []; } $r['result']['content'][] = array('type' => 'text', 'text' => $text); }; $cleanHtml = function($v) { return wp_kses_post( wp_unslash( $v ) ); }; $postExcerpt = function($p) { return wp_trim_words( wp_strip_all_tags( isset($p->post_excerpt) && !empty($p->post_excerpt) ? $p->post_excerpt : $p->post_content ), 55 ); }; $isTruthy = function($value) { if (is_bool($value)) { return $value; } if (is_int($value)) { return 1 === $value; } if (is_string($value)) { return in_array(strtolower(trim($value)), array('1', 'true', 'yes', 'on'), true); } return !empty($value); }; $buildPaginationMeta = function($totalItems, $limit, $offset = 0, $paged = 1) { $limit = max(1, (int) $limit); $offset = max(0, (int) $offset); $paged = max(1, (int) $paged); $currentPage = $offset > 0 ? (int) floor($offset / $limit) + 1 : $paged; return array( 'total_items' => (int) $totalItems, 'per_page' => $limit, 'offset' => $offset, 'current_page' => $currentPage, 'total_pages' => (int) ceil(max(0, (int) $totalItems) / $limit), 'has_more' => ($offset + $limit) < (int) $totalItems, ); }; $buildPostAuthor = function($postObj) { $authorId = isset($postObj->post_author) ? (int) $postObj->post_author : 0; if ($authorId <= 0) { return null; } $author = get_userdata($authorId); if (!$author) { return array('ID' => $authorId); } return array( 'ID' => $author->ID, 'user_login' => $author->user_login, 'display_name' => $author->display_name, ); }; $buildFeaturedMedia = function($postId) { $attachmentId = (int) get_post_thumbnail_id($postId); if ($attachmentId <= 0) { return null; } return array( 'ID' => $attachmentId, 'url' => wp_get_attachment_url($attachmentId), 'alt_text' => get_post_meta($attachmentId, '_wp_attachment_image_alt', true), ); }; $buildPostTaxonomies = function($postObj) { $postId = isset($postObj->ID) ? (int) $postObj->ID : 0; $postType = isset($postObj->post_type) ? $postObj->post_type : get_post_type($postId); $taxonomies = get_object_taxonomies($postType, 'objects'); if (empty($taxonomies) || !is_array($taxonomies)) { return array(); } $summary = array(); foreach ($taxonomies as $taxonomy) { if (empty($taxonomy->name)) { continue; } $terms = get_the_terms($postId, $taxonomy->name); if (is_wp_error($terms) || empty($terms)) { continue; } $summary[$taxonomy->name] = array(); foreach ($terms as $term) { $summary[$taxonomy->name][] = array( 'term_id' => $term->term_id, 'name' => $term->name, 'slug' => $term->slug, ); } } return $summary; }; $sanitizeQueryParams = function($params) { $normalized = array(); if (!is_array($params)) { return $normalized; } foreach ($params as $paramName => $paramValue) { $cleanName = preg_replace('/[^A-Za-z0-9_\-\.\[\]]/', '', (string) $paramName); if ('' === $cleanName) { continue; } if (is_array($paramValue)) { $cleanValues = array(); foreach ($paramValue as $value) { if (is_scalar($value) || null === $value) { $cleanValues[] = sanitize_text_field((string) $value); } } if (!empty($cleanValues)) { $normalized[$cleanName] = $cleanValues; } continue; } if (is_scalar($paramValue) || null === $paramValue) { $normalized[$cleanName] = sanitize_text_field((string) $paramValue); } } return $normalized; }; $sanitizeHeaderMap = function($headers) { $normalized = array(); if (!is_array($headers)) { return $normalized; } foreach ($headers as $headerName => $headerValue) { $cleanName = preg_replace('/[^A-Za-z0-9\-]/', '', (string) $headerName); if ('' === $cleanName) { continue; } if (is_array($headerValue)) { $parts = array(); foreach ($headerValue as $value) { if (is_scalar($value) || null === $value) { $parts[] = trim(str_replace(array("\r", "\n"), ' ', sanitize_text_field((string) $value))); } } $cleanValue = implode(', ', array_filter($parts, function($part) { return '' !== $part; })); } elseif (is_scalar($headerValue) || null === $headerValue) { $cleanValue = trim(str_replace(array("\r", "\n"), ' ', sanitize_text_field((string) $headerValue))); } else { $cleanValue = ''; } if ('' === $cleanValue) { continue; } $normalized[$cleanName] = $cleanValue; } return $normalized; }; $hasHeaderName = function(array $headers, $headerName) { foreach (array_keys($headers) as $existingHeaderName) { if (0 === strcasecmp((string) $existingHeaderName, (string) $headerName)) { return true; } } return false; }; $normalizePostCategories = function($value) { $normalized = array(); if (is_numeric($value)) { $value = array($value); } if (!is_array($value)) { return $normalized; } foreach ($value as $categoryId) { if (!is_scalar($categoryId) && null !== $categoryId) { continue; } $parsedId = intval($categoryId); if ($parsedId > 0) { $normalized[$parsedId] = $parsedId; } } return array_values($normalized); }; $normalizeTaxInput = function($taxInput) { $normalized = array(); if (!is_array($taxInput)) { return $normalized; } foreach ($taxInput as $taxonomy => $termValues) { $taxonomyKey = sanitize_key((string) $taxonomy); if ('' === $taxonomyKey || !taxonomy_exists($taxonomyKey)) { continue; } if (is_string($termValues)) { $termValues = explode(',', $termValues); } elseif (is_numeric($termValues)) { $termValues = array($termValues); } elseif (!is_array($termValues)) { $termValues = array(); } $terms = array(); foreach ($termValues as $termValue) { if (!is_scalar($termValue) && null !== $termValue) { continue; } $termText = trim((string) $termValue); if ('' === $termText) { continue; } $terms[] = ctype_digit($termText) ? intval($termText) : sanitize_text_field($termText); } if (!empty($terms)) { $normalized[$taxonomyKey] = array_values(array_unique($terms, SORT_REGULAR)); } } return $normalized; }; $this->maybeLoadOptionalModuleForTool( $tool ); // Validate args against tool schema (basic) before dispatching $tools_map = $this->getTools(); if ( isset( $tools_map[ $tool ] ) && is_array( $tools_map[ $tool ] ) && ! empty( $tools_map[ $tool ]['inputSchema'] ) ) { $schema = $tools_map[$tool]['inputSchema']; $errMsg = ''; if (!$this->validateArgumentsSchema($schema, is_array($args) ? $args : array(), $errMsg)) { $r['error'] = array('code' => -42602, 'message' => 'Invalid arguments: ' . $errMsg); return $r; } } // --- INICIO LÓGICA DE DISPATCH ADAPTADA --- // Enforce capability mapping for mutating tools (centralized) $required_cap = $this->getToolCapability($tool); if (!empty($required_cap) && !current_user_can($required_cap)) { return array('jsonrpc' => '2.0', 'id' => $id, 'error' => array('code' => 'permission_denied', 'message' => 'Insufficient permissions to execute ' . $tool . '. Required capability: ' . $required_cap)); } $allowed_by_integration = apply_filters('sflmcp_is_tool_enabled_for_integrations', true, $tool, 'call', null); if (!$allowed_by_integration) { return array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array( 'code' => -42609, 'message' => 'Tool is disabled by plugin integration settings', ), ); } // Change Tracker: capture before-state for mutating tools $changeSnapshot = null; if ( class_exists( 'StifliFlexMcp_ChangeTracker' ) && get_option( 'sflmcp_changelog_enabled', true ) ) { $changeTracker = StifliFlexMcp_ChangeTracker::getInstance(); $changeSnapshot = $changeTracker->captureBeforeState( $tool, is_array( $args ) ? $args : array() ); } // Helper closure to record a tracked change before any early return $recordChangeIfNeeded = function() use ( $tool, $args, &$changeSnapshot, &$r ) { if ( null !== $changeSnapshot && ! isset( $r['error'] ) && class_exists( 'StifliFlexMcp_ChangeTracker' ) ) { try { $tracker = StifliFlexMcp_ChangeTracker::getInstance(); $change_id = $tracker->recordChange( $tool, is_array( $args ) ? $args : array(), $changeSnapshot, $r ); if ( $change_id && isset( $r['result']['structuredContent'] ) && is_array( $r['result']['structuredContent'] ) ) { $undo = isset( $r['result']['structuredContent']['undo'] ) && is_array( $r['result']['structuredContent']['undo'] ) ? $r['result']['structuredContent']['undo'] : array(); $undo['action_id'] = (int) $change_id; $undo['snapshot_id'] = (int) $change_id; $r['result']['structuredContent']['undo'] = $undo; $r['result']['content'] = array( array( 'type' => 'text', 'text' => wp_json_encode( $r['result']['structuredContent'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ), ), ); } } catch ( \Exception $e ) { stifli_flex_mcp_log( 'ChangeTracker error: ' . $e->getMessage() ); } } }; switch ($tool) { case 'mcp_ping': $diagnosticsEnabled = $isTruthy($utils::getArrayValue($args, 'diagnostics', false)); $timeoutSec = max(1, min(10, intval($utils::getArrayValue($args, 'timeout_sec', 3, 1)))); $homeUrl = home_url('/'); $restUrl = get_rest_url(null, '/'); $pingData = array( 'time' => gmdate('Y-m-d H:i:s'), 'name' => get_bloginfo('name'), 'home_url' => $homeUrl, 'rest_url' => $restUrl, 'https' => is_ssl(), 'wordpress_version' => get_bloginfo('version'), 'php_version' => PHP_VERSION, ); if ($diagnosticsEnabled) { $host = (string) wp_parse_url($homeUrl, PHP_URL_HOST); $resolvedIp = ''; if ('' !== $host) { $resolvedIp = (string) gethostbyname($host); } $dnsResolved = '' !== $resolvedIp && ($resolvedIp !== $host || filter_var($host, FILTER_VALIDATE_IP)); $pingData['diagnostics'] = array( 'host' => $host, 'resolved_ip' => $resolvedIp, 'dns_resolved' => (bool) $dnsResolved, 'timeout_sec' => $timeoutSec, ); $homeResponse = wp_remote_head($homeUrl, array('timeout' => $timeoutSec, 'redirection' => 3)); if (is_wp_error($homeResponse)) { $pingData['diagnostics']['home_request'] = array( 'ok' => false, 'message' => $homeResponse->get_error_message(), ); } else { $pingData['diagnostics']['home_request'] = array( 'ok' => true, 'status' => (int) wp_remote_retrieve_response_code($homeResponse), ); } $restResponse = wp_remote_get($restUrl, array('timeout' => $timeoutSec, 'redirection' => 3)); if (is_wp_error($restResponse)) { $pingData['diagnostics']['rest_request'] = array( 'ok' => false, 'message' => $restResponse->get_error_message(), ); } else { $pingData['diagnostics']['rest_request'] = array( 'ok' => true, 'status' => (int) wp_remote_retrieve_response_code($restResponse), ); } } $addResultText($r, 'Ping successful: ' . wp_json_encode($pingData, JSON_PRETTY_PRINT)); break; case 'wp_get_posts': $postsLimit = max(1, intval($utils::getArrayValue($args, 'limit', 10, 1))); $postsPaged = max(1, intval($utils::getArrayValue($args, 'paged', 1, 1))); $hasPostsOffset = array_key_exists('offset', $args); $postsOffset = $hasPostsOffset ? max(0, intval($args['offset'])) : null; // WP_Query prioritizes offset over paged; offset=0 should not force page 1 forever. $usePostsOffset = null !== $postsOffset && $postsOffset > 0; $includePostAuthor = $isTruthy($utils::getArrayValue($args, 'include_author', false)); $includePostFeaturedMedia = $isTruthy($utils::getArrayValue($args, 'include_featured_media', false)); $includePostTaxonomies = $isTruthy($utils::getArrayValue($args, 'include_taxonomies', false)); $includePostsPagination = $isTruthy($utils::getArrayValue($args, 'include_pagination', false)); $q = array( 'post_type' => sanitize_key($utils::getArrayValue($args, 'post_type', 'post')), 'post_status' => sanitize_key($utils::getArrayValue($args, 'post_status', 'publish')), 'posts_per_page' => $postsLimit, 'no_found_rows' => !$includePostsPagination, ); $postsSearch = sanitize_text_field($utils::getArrayValue($args, 'search')); if ('' !== $postsSearch) { $q['s'] = $postsSearch; } if ($usePostsOffset) { $q['offset'] = $postsOffset; } else { $q['paged'] = $postsPaged; } $date = array(); if (!empty($args['after'])) { $date['after'] = sanitize_text_field($args['after']); } if (!empty($args['before'])) { $date['before'] = sanitize_text_field($args['before']); } if ($date) { $q['date_query'] = array($date); } $postQuery = new WP_Query($q); $rows = array(); foreach ($postQuery->posts as $p) { $row = array( 'ID' => $p->ID, 'post_type' => $p->post_type, 'post_title' => $p->post_title, 'post_status' => $p->post_status, 'post_excerpt' => $postExcerpt($p), 'permalink' => get_permalink($p), ); if ($includePostAuthor) { $row['author'] = $buildPostAuthor($p); } if ($includePostFeaturedMedia) { $row['featured_media'] = $buildFeaturedMedia($p->ID); } if ($includePostTaxonomies) { $row['taxonomies'] = $buildPostTaxonomies($p); } $rows[] = $row; } if ($includePostsPagination) { $effectivePostsOffset = $usePostsOffset ? $postsOffset : (($postsPaged - 1) * $postsLimit); $rows = array( 'items' => $rows, 'pagination' => $buildPaginationMeta((int) $postQuery->found_posts, $postsLimit, $effectivePostsOffset, $postsPaged), ); } $addResultText($r, wp_json_encode($rows, JSON_PRETTY_PRINT)); break; case 'wp_get_post': if (empty($args['ID'])) { $r['error'] = array('code' => -42602, 'message' => 'ID required'); break; } $p = get_post(intval($args['ID'])); if (!$p) { $r['error'] = array('code' => -42600, 'message' => 'Post not found'); break; } $includeSingleAuthor = $isTruthy($utils::getArrayValue($args, 'include_author', false)); $includeSingleFeaturedMedia = $isTruthy($utils::getArrayValue($args, 'include_featured_media', false)); $includeSingleTaxonomies = $isTruthy($utils::getArrayValue($args, 'include_taxonomies', false)); $out = array( 'ID' => $p->ID, 'post_type' => $p->post_type, 'post_title' => $p->post_title, 'post_status' => $p->post_status, 'post_content' => $cleanHtml($p->post_content), 'post_excerpt' => $postExcerpt($p), 'permalink' => get_permalink($p), 'post_date' => $p->post_date, 'post_modified' => $p->post_modified, ); if ($includeSingleAuthor) { $out['author'] = $buildPostAuthor($p); } if ($includeSingleFeaturedMedia) { $out['featured_media'] = $buildFeaturedMedia($p->ID); } if ($includeSingleTaxonomies) { $out['taxonomies'] = $buildPostTaxonomies($p); } $addResultText($r, wp_json_encode($out, JSON_PRETTY_PRINT)); break; case 'wp_create_post': if (empty($args['post_title'])) { $r['error'] = array('code' => -42602, 'message' => 'post_title required'); break; } $cp_post_type = sanitize_key($utils::getArrayValue($args, 'post_type', 'post')); if (!post_type_exists($cp_post_type)) { $r['error'] = array('code' => -42600, 'message' => 'Unknown post_type: ' . $cp_post_type); break; } $cp_pt_obj = get_post_type_object($cp_post_type); if ($cp_pt_obj && empty($cp_pt_obj->public) && empty($cp_pt_obj->show_ui)) { $r['error'] = array('code' => -42600, 'message' => 'post_type "' . $cp_post_type . '" is not exposed via UI/public.'); break; } $cp_create_cap = ($cp_pt_obj && !empty($cp_pt_obj->cap->edit_posts)) ? $cp_pt_obj->cap->edit_posts : 'edit_posts'; if (!current_user_can($cp_create_cap)) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions to create ' . $cp_post_type); break; } $ins = array( 'post_title' => sanitize_text_field($args['post_title']), 'post_status' => sanitize_key($utils::getArrayValue($args, 'post_status', 'draft')), 'post_type' => $cp_post_type, ); if (!empty($args['post_content'])) { $ins['post_content'] = $args['post_content']; } if (!empty($args['post_excerpt'])) { $ins['post_excerpt'] = $cleanHtml($args['post_excerpt']); } if (!empty($args['post_name'])) { $ins['post_name'] = sanitize_title($args['post_name']); } if (isset($args['post_author'])) { $cp_author_id = intval($args['post_author']); if ($cp_author_id <= 0 || !get_userdata($cp_author_id)) { $r['error'] = array('code' => -42600, 'message' => 'post_author user not found.'); break; } $cp_others_cap = ($cp_pt_obj && !empty($cp_pt_obj->cap->edit_others_posts)) ? $cp_pt_obj->cap->edit_others_posts : 'edit_others_posts'; if ($cp_author_id !== get_current_user_id() && !current_user_can($cp_others_cap)) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions to assign post_author of ' . $cp_post_type); break; } $ins['post_author'] = $cp_author_id; } $cp_post_categories = array(); if (array_key_exists('post_category', $args)) { $cp_post_categories = $normalizePostCategories($args['post_category']); if (!empty($cp_post_categories)) { $ins['post_category'] = $cp_post_categories; } } $cp_tax_input = array(); if (array_key_exists('tax_input', $args) && is_array($args['tax_input'])) { $cp_tax_input = $normalizeTaxInput($args['tax_input']); if (!empty($cp_tax_input)) { $ins['tax_input'] = $cp_tax_input; } } if (!empty($args['meta_input']) && is_array($args['meta_input'])) { $ins['meta_input'] = $args['meta_input']; } $new = wp_insert_post($ins, true); if (is_wp_error($new)) { $r['error'] = array('code' => $new->get_error_code(), 'message' => $new->get_error_message()); } else { if (empty($ins['meta_input']) && !empty($args['meta_input']) && is_array($args['meta_input'])) { foreach ($args['meta_input'] as $k => $v) { update_post_meta($new, sanitize_key($k), maybe_serialize($v)); } } if (!empty($cp_post_categories)) { wp_set_post_categories($new, $cp_post_categories, false); } if (!empty($cp_tax_input)) { foreach ($cp_tax_input as $cp_taxonomy => $cp_terms) { wp_set_post_terms($new, $cp_terms, $cp_taxonomy, false); } } // Featured image (post thumbnail). if ( isset( $args['featured_media'] ) ) { $att_id = intval( $args['featured_media'] ); if ( $att_id > 0 ) { $att = get_post( $att_id ); if ( $att && 'attachment' === $att->post_type ) { set_post_thumbnail( $new, $att_id ); } } } $addResultText($r, 'Post created ID ' . $new); } break; case 'wp_update_post': if (empty($args['ID'])) { $r['error'] = array('code' => -42602, 'message' => 'ID required'); break; } $up_id = intval($args['ID']); $up_existing = get_post($up_id); if (!$up_existing) { $r['error'] = array('code' => -42600, 'message' => 'Post not found'); break; } $up_pt_obj = get_post_type_object($up_existing->post_type); $c = array('ID' => $up_id); $up_post_categories = array(); $up_tax_input = array(); if (!empty($args['fields']) && is_array($args['fields'])) { foreach ($args['fields'] as $k => $v) { // Validate post_type change if ('post_type' === $k) { $new_pt = sanitize_key($v); if (!post_type_exists($new_pt)) { $r['error'] = array('code' => -42600, 'message' => 'Unknown post_type: ' . $new_pt); break 2; } $new_pt_obj = get_post_type_object($new_pt); if ($new_pt_obj && empty($new_pt_obj->public) && empty($new_pt_obj->show_ui)) { $r['error'] = array('code' => -42600, 'message' => 'post_type "' . $new_pt . '" is not exposed via UI/public.'); break 2; } $c[$k] = $new_pt; continue; } // Validate post_author change if ('post_author' === $k) { $up_author_id = intval($v); if ($up_author_id <= 0 || !get_userdata($up_author_id)) { $r['error'] = array('code' => -42600, 'message' => 'post_author user not found.'); break 2; } $up_others_cap = ($up_pt_obj && !empty($up_pt_obj->cap->edit_others_posts)) ? $up_pt_obj->cap->edit_others_posts : 'edit_others_posts'; if ($up_author_id !== get_current_user_id() && !current_user_can($up_others_cap, $up_id)) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions to reassign post_author.'); break 2; } $c[$k] = $up_author_id; continue; } if ('post_category' === $k) { $up_post_categories = $normalizePostCategories($v); if (!empty($up_post_categories)) { $c['post_category'] = $up_post_categories; } continue; } if ('tax_input' === $k) { if (is_array($v)) { $up_tax_input = $normalizeTaxInput($v); if (!empty($up_tax_input)) { $c['tax_input'] = $up_tax_input; } } continue; } $c[$k] = in_array($k, array('post_content', 'post_excerpt'), true) ? $cleanHtml($v) : sanitize_text_field($v); } } if (array_key_exists('post_category', $args)) { $up_post_categories = $normalizePostCategories($args['post_category']); if (!empty($up_post_categories)) { $c['post_category'] = $up_post_categories; } } if (array_key_exists('tax_input', $args) && is_array($args['tax_input'])) { $up_tax_input = $normalizeTaxInput($args['tax_input']); if (!empty($up_tax_input)) { $c['tax_input'] = $up_tax_input; } } $u = ( count($c) > 1 ) ? wp_update_post($c, true) : $c['ID']; if (is_wp_error($u)) { $r['error'] = array('code' => $u->get_error_code(), 'message' => $u->get_error_message()); break; } if (!empty($up_post_categories)) { wp_set_post_categories($u, $up_post_categories, false); } if (!empty($up_tax_input)) { foreach ($up_tax_input as $up_taxonomy => $up_terms) { wp_set_post_terms($u, $up_terms, $up_taxonomy, false); } } if (!empty($args['meta_input']) && is_array($args['meta_input'])) { foreach ($args['meta_input'] as $k => $v) { update_post_meta($u, sanitize_key($k), maybe_serialize($v)); } } if ( isset( $args['featured_media'] ) ) { $att_id = intval( $args['featured_media'] ); if ( $att_id > 0 ) { $att = get_post( $att_id ); if ( $att && 'attachment' === $att->post_type ) { set_post_thumbnail( $u, $att_id ); } } else { delete_post_thumbnail( $u ); } } $addResultText($r, 'Post #' . $u . ' updated'); break; case 'wp_set_featured_image': $post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); if ( ! $post_id ) { $r['error'] = array( 'code' => -42602, 'message' => 'post_id required' ); break; } $post_obj = get_post( $post_id ); if ( ! $post_obj ) { $r['error'] = array( 'code' => -42600, 'message' => 'Post not found' ); break; } if ( ! current_user_can( 'edit_post', $post_id ) ) { $r['error'] = array( 'code' => 'permission_denied', 'message' => 'Insufficient permissions to edit this post.' ); break; } $att_id = intval( $utils::getArrayValue( $args, 'attachment_id', 0 ) ); if ( $att_id > 0 ) { $att = get_post( $att_id ); if ( ! $att || 'attachment' !== $att->post_type ) { $r['error'] = array( 'code' => -42600, 'message' => 'Attachment not found' ); break; } $ok = set_post_thumbnail( $post_id, $att_id ); if ( $ok ) { $addResultText( $r, 'Featured image set: post #' . $post_id . ' -> attachment #' . $att_id ); } else { $r['error'] = array( 'code' => -42603, 'message' => 'Failed to set featured image' ); } } else { delete_post_thumbnail( $post_id ); $addResultText( $r, 'Featured image cleared for post #' . $post_id ); } break; case 'wp_delete_post': if (empty($args['ID'])) { $r['error'] = array('code' => -42602, 'message' => 'ID required'); break; } $del = wp_delete_post(intval($args['ID']), !empty($args['force'])); if ($del) { $addResultText($r, 'Post #' . $args['ID'] . ' deleted'); } else { $r['error'] = array('code' => -42603, 'message' => 'Deletion failed'); } break; // Pages (son posts con post_type='page') case 'wp_get_pages': $pargs = array( 'post_type' => 'page', 'post_status' => $utils::getArrayValue($args, 'post_status', 'publish'), 'numberposts' => max(1, $utils::getArrayValue($args, 'limit', 10, 1)), 'orderby' => $utils::getArrayValue($args, 'orderby', 'date'), 'order' => $utils::getArrayValue($args, 'order', 'DESC'), ); if (isset($args['search'])) { $pargs['s'] = sanitize_text_field($args['search']); } if (isset($args['offset'])) { $pargs['offset'] = max(0, intval($args['offset'])); } $list = array(); foreach (get_posts($pargs) as $p) { $list[] = array( 'ID' => $p->ID, 'post_title' => $p->post_title, 'post_status' => $p->post_status, 'post_date' => $p->post_date, 'post_modified' => $p->post_modified, 'post_author' => $p->post_author, 'post_parent' => $p->post_parent, 'menu_order' => $p->menu_order, ); } $addResultText($r, wp_json_encode($list, JSON_PRETTY_PRINT)); break; case 'wp_create_page': $pdata = array( 'post_type' => 'page', 'post_title' => $cleanHtml($utils::getArrayValue($args, 'post_title', '')), 'post_content' => $cleanHtml($utils::getArrayValue($args, 'post_content', '')), 'post_status' => $utils::getArrayValue($args, 'post_status', 'draft'), ); if (!empty($args['post_author'])) { $pdata['post_author'] = intval($args['post_author']); } if (isset($args['post_parent'])) { $pdata['post_parent'] = intval($args['post_parent']); } if (isset($args['menu_order'])) { $pdata['menu_order'] = intval($args['menu_order']); } if (!empty($args['meta_input']) && is_array($args['meta_input'])) { $pdata['meta_input'] = $args['meta_input']; } $u = wp_insert_post($pdata, true); if (is_wp_error($u)) { $r['error'] = array('code' => -42603, 'message' => $u->get_error_message()); } else { $addResultText($r, 'Page #' . $u . ' created'); } break; case 'wp_update_page': if (empty($args['ID'])) { $r['error'] = array('code' => -42602, 'message' => 'ID required'); break; } $pdata = array('ID' => intval($args['ID']), 'post_type' => 'page'); foreach (array('post_title', 'post_content', 'post_status', 'post_author', 'post_parent', 'menu_order') as $k) { if (isset($args[$k])) { $pdata[$k] = in_array($k, array('post_title', 'post_content'), true) ? $cleanHtml($args[$k]) : $args[$k]; } } $u = wp_update_post($pdata, true); if (is_wp_error($u)) { $r['error'] = array('code' => -42603, 'message' => $u->get_error_message()); break; } if (!empty($args['meta_input']) && is_array($args['meta_input'])) { foreach ($args['meta_input'] as $k => $v) { update_post_meta($u, sanitize_key($k), maybe_serialize($v)); } } $addResultText($r, 'Page #' . $u . ' updated'); break; case 'wp_delete_page': if (empty($args['ID'])) { $r['error'] = array('code' => -42602, 'message' => 'ID required'); break; } $del = wp_delete_post(intval($args['ID']), !empty($args['force'])); if ($del) { $addResultText($r, 'Page #' . $args['ID'] . ' deleted'); } else { $r['error'] = array('code' => -42603, 'message' => 'Deletion failed'); } break; case 'wp_get_comments': $commentsLimit = max(1, $utils::getArrayValue($args, 'limit', 10, 1)); $commentsPaged = max(1, intval($utils::getArrayValue($args, 'paged', 1, 1))); $commentsOffset = isset($args['offset']) ? max(0, intval($args['offset'])) : (($commentsPaged - 1) * $commentsLimit); $includeCommentPostTitle = $isTruthy($utils::getArrayValue($args, 'include_post_title', false)); $includeCommentsPagination = $isTruthy($utils::getArrayValue($args, 'include_pagination', false)); $cargs = array( 'status' => $utils::getArrayValue($args, 'status', 'approve'), 'number' => $commentsLimit, 'offset' => $commentsOffset, ); $commentsPostId = $utils::getArrayValue($args, 'post_id', 0, 1); if (!empty($commentsPostId)) { $cargs['post_id'] = $commentsPostId; } $commentsSearch = sanitize_text_field($utils::getArrayValue($args, 'search')); if ('' !== $commentsSearch) { $cargs['search'] = $commentsSearch; } $commentsDate = array(); if (!empty($args['after'])) { $commentsDate['after'] = sanitize_text_field($args['after']); } if (!empty($args['before'])) { $commentsDate['before'] = sanitize_text_field($args['before']); } if (!empty($commentsDate)) { $cargs['date_query'] = array($commentsDate); } $list = array(); foreach (get_comments($cargs) as $c) { // Mask author email and IP for privacy/GDPR; full data is // available natively in WP admin to users with the cap. $row = array( 'comment_ID' => $c->comment_ID, 'comment_post_ID' => $c->comment_post_ID, 'comment_author' => $c->comment_author, 'comment_author_email' => StifliFlexMcpUtils::maskEmail( (string) $c->comment_author_email ), 'comment_author_IP' => StifliFlexMcpUtils::maskIp( (string) $c->comment_author_IP ), 'comment_content' => wp_trim_words(wp_strip_all_tags($c->comment_content), 40), 'comment_date' => $c->comment_date, 'comment_approved' => $c->comment_approved, ); if ($includeCommentPostTitle) { $row['post_title'] = get_the_title($c->comment_post_ID); } $list[] = $row; } if ($includeCommentsPagination) { $countArgs = $cargs; unset($countArgs['number'], $countArgs['offset']); $countArgs['count'] = true; $list = array( 'items' => $list, 'pagination' => $buildPaginationMeta((int) get_comments($countArgs), $commentsLimit, $commentsOffset, $commentsPaged), ); } $addResultText($r, wp_json_encode($list, JSON_PRETTY_PRINT)); break; case 'wp_create_comment': if (empty($args['post_id']) || empty($args['comment_content'])) { $r['error'] = array('code' => -42602, 'message' => 'post_id & comment_content required'); break; } $ins = array( 'comment_post_ID' => intval($args['post_id']), 'comment_content' => $cleanHtml($args['comment_content']), 'comment_author' => sanitize_text_field($utils::getArrayValue($args, 'comment_author')), 'comment_author_email' => sanitize_email($utils::getArrayValue($args, 'comment_author_email')), 'comment_author_url' => esc_url_raw($utils::getArrayValue($args, 'comment_author_url')), 'comment_approved' => $utils::getArrayValue($args, 'comment_approved', 1), ); $cid = wp_insert_comment($ins); if (is_wp_error($cid)) { $r['error'] = array( 'code' => $cid instanceof WP_Error ? $cid->get_error_code() : -1, 'message' => $cid instanceof WP_Error ? $cid->get_error_message() : 'Unknown error occurred.' ); } elseif ($cid === false) { $r['error'] = array( 'code' => -1, 'message' => 'Unknown error occurred while creating the comment.' ); } elseif (is_int($cid)) { $addResultText($r, 'Comment created successfully with ID ' . $cid); } else { $r['error'] = array( 'code' => -1, 'message' => 'Unexpected return type from wp_insert_comment.' ); } break; case 'wp_update_comment': if (empty($args['comment_ID'])) { $r['error'] = array('code' => -42602, 'message' => 'comment_ID required'); break; } $c = array('comment_ID' => intval($args['comment_ID'])); if (!empty($args['fields']) && is_array($args['fields'])) { foreach ($args['fields'] as $k => $v) { $c[$k] = ( 'comment_content' === $k ) ? $cleanHtml($v) : sanitize_text_field($v); } } $cid = wp_update_comment($c, true); if (is_wp_error($cid)) { $r['error'] = array('code' => $cid->get_error_code(), 'message' => $cid->get_error_message()); } else { $addResultText($r, 'Comment #' . $cid . ' updated'); } break; case 'wp_delete_comment': if (empty($args['comment_ID'])) { $r['error'] = array('code' => -42602, 'message' => 'comment_ID required'); break; } $done = wp_delete_comment(intval($args['comment_ID']), !empty($args['force'])); if ($done) { $addResultText($r, 'Comment #' . $args['comment_ID'] . ' deleted'); } else { $r['error'] = array('code' => -42603, 'message' => 'Deletion failed'); } break; case 'wp_get_users': $usersLimit = max(1, intval($utils::getArrayValue($args, 'limit', 10, 1))); $usersPaged = max(1, intval($utils::getArrayValue($args, 'paged', 1, 1))); $usersOffset = isset($args['offset']) ? max(0, intval($args['offset'])) : (($usersPaged - 1) * $usersLimit); $includeRegisteredDate = $isTruthy($utils::getArrayValue($args, 'include_registered_date', false)); $includeAvatarUrl = $isTruthy($utils::getArrayValue($args, 'include_avatar_url', false)); $includePostCounts = $isTruthy($utils::getArrayValue($args, 'include_post_counts', false)); $includeUsersPagination = $isTruthy($utils::getArrayValue($args, 'include_pagination', false)); $q = array( 'number' => $usersLimit, 'offset' => $usersOffset, 'count_total' => $includeUsersPagination, ); $usersSearch = trim((string) $utils::getArrayValue($args, 'search')); if ('' !== $usersSearch) { $q['search'] = '*' . esc_attr($usersSearch) . '*'; $q['search_columns'] = array('user_login', 'user_email', 'user_nicename', 'display_name'); } $usersRole = $utils::getArrayValue($args, 'role'); if (!empty($usersRole)) { $q['role'] = sanitize_text_field($usersRole); } $userQuery = new WP_User_Query($q); $rows = array(); foreach ($userQuery->get_results() as $u) { $row = array( 'ID' => $u->ID, 'user_login' => $u->user_login, 'display_name' => $u->display_name, 'roles' => $u->roles, ); if ($includeRegisteredDate) { $row['user_registered'] = $u->user_registered; } if ($includeAvatarUrl) { $row['avatar_url'] = get_avatar_url($u->ID); } if ($includePostCounts) { $row['post_count'] = (int) count_user_posts($u->ID); } $rows[] = $row; } if ($includeUsersPagination) { $rows = array( 'items' => $rows, 'pagination' => $buildPaginationMeta((int) $userQuery->get_total(), $usersLimit, $usersOffset, $usersPaged), ); } $addResultText($r, wp_json_encode($rows, JSON_PRETTY_PRINT)); break; // Removed for WordPress.org compliance: wp_create_user, wp_update_user, wp_delete_user // User Meta case 'wp_get_user_meta': $user_id = intval($utils::getArrayValue($args, 'user_id', 0)); if (empty($user_id)) { $r['error'] = array('code' => -42602, 'message' => 'user_id required'); break; } $meta_key = $utils::getArrayValue($args, 'meta_key', ''); if (!empty($meta_key)) { $value = get_user_meta($user_id, sanitize_key($meta_key), true); if ( StifliFlexMcpUtils::keyLooksSensitive( $meta_key ) ) { $value = is_scalar( $value ) && '' !== (string) $value ? '[REDACTED]' : StifliFlexMcpUtils::redactSecrets( $value, $meta_key ); } else { $value = StifliFlexMcpUtils::redactSecrets( $value, $meta_key ); } $addResultText($r, 'User meta ' . $meta_key . ': ' . wp_json_encode($value, JSON_PRETTY_PRINT)); } else { // Get all meta $all_meta = get_user_meta($user_id); $cleaned = array(); foreach ($all_meta as $key => $values) { $val = count($values) === 1 ? $values[0] : $values; if ( StifliFlexMcpUtils::keyLooksSensitive( $key ) ) { $cleaned[$key] = is_scalar( $val ) && '' !== (string) $val ? '[REDACTED]' : '[REDACTED]'; } else { $cleaned[$key] = StifliFlexMcpUtils::redactSecrets( $val, $key ); } } $addResultText($r, 'All user meta for user #' . $user_id . ': ' . wp_json_encode($cleaned, JSON_PRETTY_PRINT)); } break; case 'wp_update_user_meta': $user_id = intval($utils::getArrayValue($args, 'user_id', 0)); $meta_key = $utils::getArrayValue($args, 'meta_key', ''); $meta_value = $utils::getArrayValue($args, 'meta_value', ''); if (empty($user_id) || empty($meta_key)) { $r['error'] = array('code' => -42602, 'message' => 'user_id and meta_key required'); break; } $updated = update_user_meta($user_id, sanitize_key($meta_key), $meta_value); if ($updated !== false) { $addResultText($r, 'User meta updated for user #' . $user_id . ', key: ' . $meta_key); } else { $r['error'] = array('code' => -42603, 'message' => 'Failed to update user meta'); } break; case 'wp_delete_user_meta': $user_id = intval($utils::getArrayValue($args, 'user_id', 0)); $meta_key = $utils::getArrayValue($args, 'meta_key', ''); if (empty($user_id) || empty($meta_key)) { $r['error'] = array('code' => -42602, 'message' => 'user_id and meta_key required'); break; } $deleted = delete_user_meta($user_id, sanitize_key($meta_key)); if ($deleted) { $addResultText($r, 'User meta deleted for user #' . $user_id . ', key: ' . $meta_key); } else { $r['error'] = array('code' => -42603, 'message' => 'Failed to delete user meta'); } break; case 'wp_list_plugins': if (!function_exists('get_plugins')) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } $all = get_plugins(); $rows = array(); foreach ($all as $file => $meta) { $rows[] = array('file' => $file, 'Name' => $meta['Name'] ?? '', 'Version' => $meta['Version'] ?? '', 'active' => is_plugin_active($file)); } $addResultText($r, wp_json_encode($rows, JSON_PRETTY_PRINT)); break; // Removed cases for WordPress.org compliance (Issues #5 & #6): // - wp_activate_plugin, wp_deactivate_plugin, wp_install_plugin (Issue #5) // - wp_install_theme, wp_switch_theme (Issue #6) case 'wp_get_themes': $themes = wp_get_themes(); $out = array(); foreach ($themes as $slug => $theme) { $out[] = array('slug' => $slug, 'Name' => $theme->get('Name'), 'Version' => $theme->get('Version')); } $addResultText($r, wp_json_encode($out, JSON_PRETTY_PRINT)); break; case 'wp_get_media': $q = array('post_type' => 'attachment', 'posts_per_page' => max(1, intval($utils::getArrayValue($args, 'limit', 20, 1)))); if (isset($args['offset'])) { $q['offset'] = max(0, intval($args['offset'])); } $rows = array(); foreach (get_posts($q) as $a) { $rows[] = array('ID' => $a->ID, 'post_title' => $a->post_title, 'mime_type' => get_post_mime_type($a), 'url' => wp_get_attachment_url($a->ID)); } $addResultText($r, wp_json_encode($rows, JSON_PRETTY_PRINT)); break; case 'wp_get_media_item': if (empty($args['ID'])) { $r['error'] = array('code' => -42602, 'message' => 'ID required'); break; } $att = get_post(intval($args['ID'])); if (!$att || 'attachment' !== $att->post_type) { $r['error'] = array('code' => -42600, 'message' => 'Media not found'); break; } $meta = wp_get_attachment_metadata($att->ID); $out = array('ID' => $att->ID, 'post_title' => $att->post_title, 'mime_type' => get_post_mime_type($att), 'url' => wp_get_attachment_url($att->ID), 'meta' => $meta); $addResultText($r, wp_json_encode($out, JSON_PRETTY_PRINT)); break; case 'wp_upload_image_from_url': $url = esc_url_raw($utils::getArrayValue($args, 'url')); // Debug logging (remove for production or wrap in WP_DEBUG check) // stifli_flex_mcp_log('wp_upload_image_from_url: URL received = ' . $url); if (!$url) { $r['error'] = array('code' => -42602, 'message' => 'url required'); break; } if (!current_user_can('upload_files')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions to upload files'); break; } // SSRF protection: HTTPS only, block private/reserved IPs and internal hosts. // Allow opting out only via filter (e.g. for local-dev environments). $require_https = (bool) apply_filters( 'sflmcp_upload_require_https', true, $url ); $url_check = StifliFlexMcpUtils::validateOutboundUrl( $url, $require_https ); if ( is_wp_error( $url_check ) ) { stifli_flex_mcp_log( 'wp_upload_image_from_url: blocked URL = ' . $url . ' reason=' . $url_check->get_error_code() ); $r['error'] = array( 'code' => $url_check->get_error_code(), 'message' => 'URL rejected: ' . $url_check->get_error_message(), ); break; } // Restrict allowed image MIME types. SVG and HTML/script-capable // formats are explicitly excluded to avoid stored XSS. $allowed_image_mimes = apply_filters( 'sflmcp_upload_allowed_image_mimes', array( 'image/jpeg' => array( 'jpg', 'jpeg', 'jpe' ), 'image/png' => array( 'png' ), 'image/gif' => array( 'gif' ), 'image/webp' => array( 'webp' ), ) ); add_filter('upload_mimes', function($mimes) use ( $allowed_image_mimes ) { foreach ( $allowed_image_mimes as $mime => $exts ) { $key = implode( '|', $exts ); $mimes[ $key ] = $mime; } return $mimes; }); stifli_flex_mcp_log('wp_upload_image_from_url: Starting download...'); if (!function_exists('download_url')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if (!function_exists('media_handle_sideload')) { require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; } // Cap download size at 20MB by default (filterable). $max_bytes = (int) apply_filters( 'sflmcp_upload_max_bytes', 20 * 1024 * 1024, $url ); $tmp = download_url( $url, 30, false ); if (is_wp_error($tmp)) { stifli_flex_mcp_log('wp_upload_image_from_url: Download error = ' . $tmp->get_error_message()); $r['error'] = array('code' => 'download_error', 'message' => $tmp->get_error_message()); break; } // Enforce size limit after download. if ( $max_bytes > 0 && file_exists( $tmp ) && filesize( $tmp ) > $max_bytes ) { wp_delete_file( $tmp ); $r['error'] = array( 'code' => 'file_too_large', 'message' => 'Downloaded file exceeds the maximum allowed size (' . $max_bytes . ' bytes).', ); break; } stifli_flex_mcp_log('wp_upload_image_from_url: Downloaded to temp file = ' . $tmp); // Get file extension from URL or detect from downloaded file $file = array(); $basename = wp_basename($url); stifli_flex_mcp_log('wp_upload_image_from_url: Original basename = ' . $basename); $parsed_url = wp_parse_url($url); $path = isset($parsed_url['path']) ? $parsed_url['path'] : ''; // If URL doesn't have a clear extension (e.g., Unsplash URLs), detect from file if (!preg_match('/\.(jpg|jpeg|png|gif|webp)$/i', $basename)) { stifli_flex_mcp_log('wp_upload_image_from_url: No extension in URL, detecting MIME type...'); // Try to detect MIME type from file content if (function_exists('mime_content_type')) { $mime = mime_content_type($tmp); } else { $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime = finfo_file($finfo, $tmp); finfo_close($finfo); } stifli_flex_mcp_log('wp_upload_image_from_url: Detected MIME type = ' . $mime); $ext = 'jpg'; // default if (strpos($mime, 'png') !== false) $ext = 'png'; else if (strpos($mime, 'gif') !== false) $ext = 'gif'; else if (strpos($mime, 'webp') !== false) $ext = 'webp'; $basename = 'image-' . time() . '.' . $ext; stifli_flex_mcp_log('wp_upload_image_from_url: New basename = ' . $basename); } $file['name'] = $basename; $file['tmp_name'] = $tmp; // Force proper MIME type $file_info = wp_check_filetype($basename); $file['type'] = $file_info['type']; // Validate the actual downloaded file is a real image of an allowed MIME. $detected_mime = ''; if ( function_exists( 'finfo_open' ) ) { $finfo_v = finfo_open( FILEINFO_MIME_TYPE ); if ( $finfo_v ) { $detected_mime = (string) finfo_file( $finfo_v, $tmp ); finfo_close( $finfo_v ); } } elseif ( function_exists( 'mime_content_type' ) ) { $detected_mime = (string) mime_content_type( $tmp ); } $detected_mime = strtolower( $detected_mime ); if ( ! isset( $allowed_image_mimes[ $detected_mime ] ) ) { wp_delete_file( $tmp ); $r['error'] = array( 'code' => 'mime_not_allowed', 'message' => 'Downloaded file MIME (' . $detected_mime . ') is not an allowed image type.', ); break; } // Final sanity: getimagesize() must succeed (rules out SVG/HTML disguised as image). $imgsize = @getimagesize( $tmp ); if ( ! is_array( $imgsize ) || empty( $imgsize[2] ) ) { wp_delete_file( $tmp ); $r['error'] = array( 'code' => 'invalid_image', 'message' => 'Downloaded file is not a valid image.', ); break; } $fileLog = wp_json_encode($file); if (false === $fileLog) { $fileLog = '[unserializable]'; } stifli_flex_mcp_log('wp_upload_image_from_url: File array = ' . $fileLog); stifli_flex_mcp_log('wp_upload_image_from_url: Calling media_handle_sideload...'); $att_id = media_handle_sideload($file, 0); if (is_wp_error($att_id)) { stifli_flex_mcp_log('wp_upload_image_from_url: Sideload error = ' . $att_id->get_error_message()); $errorDataLog = wp_json_encode($att_id->get_error_data()); if (false === $errorDataLog) { $errorDataLog = '[unserializable]'; } stifli_flex_mcp_log('wp_upload_image_from_url: Sideload error data = ' . $errorDataLog); @wp_delete_file($file['tmp_name']); $r['error'] = array('code' => 'sideload_error', 'message' => $att_id->get_error_message()); break; } stifli_flex_mcp_log('wp_upload_image_from_url: Success! Attachment ID = ' . $att_id); $att_url = wp_get_attachment_url($att_id); // Set alt text and title if provided $alt_text = sanitize_text_field($utils::getArrayValue($args, 'alt_text', '')); $title = sanitize_text_field($utils::getArrayValue($args, 'title', '')); if ($alt_text) update_post_meta($att_id, '_wp_attachment_image_alt', $alt_text); if ($title) wp_update_post(array('ID' => $att_id, 'post_title' => $title)); $addResultText($r, 'Image uploaded successfully. Attachment ID: ' . $att_id . ', URL: ' . $att_url); break; case 'wp_upload_image': $image_data = $utils::getArrayValue($args, 'image_data'); $filename = sanitize_file_name($utils::getArrayValue($args, 'filename', 'image.png')); $post_id = intval($utils::getArrayValue($args, 'post_id', 0)); if (!$image_data) { $r['error'] = array('code' => -42602, 'message' => 'image_data required'); break; } if (!current_user_can('upload_files')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions to upload files'); break; } // Normalize base64: accept data URL or raw base64 $detected_mime = null; $image_data = trim($image_data); if (preg_match('/^data:([^;]+);base64,(.*)$/s', $image_data, $b64match)) { $detected_mime = $b64match[1]; $image_data = $b64match[2]; } // Strip whitespace/newlines that LLMs may inject $image_data = preg_replace('/\s+/', '', $image_data); // Fix base64 padding if missing $pad = strlen($image_data) % 4; if ($pad) { $image_data .= str_repeat('=', 4 - $pad); } $decoded = base64_decode($image_data, true); if ($decoded === false || strlen($decoded) < 8) { $r['error'] = array('code' => -42602, 'message' => 'Invalid base64 data'); break; } // Detect MIME from binary header if not from data URL if (!$detected_mime) { $header = substr($decoded, 0, 16); if (substr($header, 0, 8) === "\x89PNG\r\n\x1a\n") { $detected_mime = 'image/png'; } elseif (substr($header, 0, 2) === "\xff\xd8") { $detected_mime = 'image/jpeg'; } elseif (substr($header, 0, 4) === 'GIF8') { $detected_mime = 'image/gif'; } elseif (substr($header, 0, 4) === 'RIFF' && substr($header, 8, 4) === 'WEBP') { $detected_mime = 'image/webp'; } } // Ensure filename has the right extension based on detected MIME if ($detected_mime) { $mime_to_ext = array('image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp'); $ext = isset($mime_to_ext[$detected_mime]) ? $mime_to_ext[$detected_mime] : null; if ($ext) { $current_ext = strtolower(pathinfo($filename, PATHINFO_EXTENSION)); if (!$current_ext || !in_array($current_ext, array('png', 'jpg', 'jpeg', 'gif', 'webp'), true)) { $filename = pathinfo($filename, PATHINFO_FILENAME) . '.' . $ext; $filename = sanitize_file_name($filename); } } } // Save to temp file if (!function_exists('wp_upload_dir')) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if (!function_exists('media_handle_sideload')) { require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; } $upload_dir = wp_upload_dir(); $temp_file = $upload_dir['path'] . '/' . wp_unique_filename($upload_dir['path'], $filename); if (file_put_contents($temp_file, $decoded) === false) { $r['error'] = array('code' => 'write_error', 'message' => 'Failed to write file'); break; } // Create attachment $file_array = array( 'name' => $filename, 'tmp_name' => $temp_file, ); $att_id = media_handle_sideload($file_array, $post_id); if (is_wp_error($att_id)) { @wp_delete_file($temp_file); $r['error'] = array('code' => 'upload_error', 'message' => $att_id->get_error_message()); break; } // Set alt text and title if provided $alt_text = sanitize_text_field($utils::getArrayValue($args, 'alt_text', '')); $title = sanitize_text_field($utils::getArrayValue($args, 'title', '')); if ($alt_text) update_post_meta($att_id, '_wp_attachment_image_alt', $alt_text); if ($title) wp_update_post(array('ID' => $att_id, 'post_title' => $title)); $att_url = wp_get_attachment_url($att_id); $addResultText($r, 'Image uploaded successfully. Attachment ID: ' . $att_id . ', URL: ' . $att_url); break; case 'wp_update_media_item': if (empty($args['ID'])) { $r['error'] = array('code' => -42602, 'message' => 'ID required'); break; } $att = get_post(intval($args['ID'])); if (!$att || 'attachment' !== $att->post_type) { $r['error'] = array('code' => -42600, 'message' => 'Media not found'); break; } $upd = array('ID' => intval($args['ID'])); if (isset($args['post_title'])) { $upd['post_title'] = sanitize_text_field($args['post_title']); } if (isset($args['post_content'])) { $upd['post_content'] = sanitize_textarea_field($args['post_content']); } if (isset($args['post_excerpt'])) { $upd['post_excerpt'] = sanitize_textarea_field($args['post_excerpt']); } $result = wp_update_post($upd, true); if (is_wp_error($result)) { $r['error'] = array('code' => $result->get_error_code(), 'message' => $result->get_error_message()); } else { $addResultText($r, 'Media item #' . $args['ID'] . ' updated'); } break; case 'wp_delete_media_item': if (empty($args['ID'])) { $r['error'] = array('code' => -42602, 'message' => 'ID required'); break; } $att = get_post(intval($args['ID'])); if (!$att || 'attachment' !== $att->post_type) { $r['error'] = array('code' => -42600, 'message' => 'Media not found'); break; } $force = isset($args['force']) ? (bool)$args['force'] : false; $deleted = wp_delete_attachment(intval($args['ID']), $force); if ($deleted) { $addResultText($r, 'Media item #' . $args['ID'] . ' deleted'); } else { $r['error'] = array('code' => -42603, 'message' => 'Media deletion failed'); } break; case 'wp_get_taxonomies': $tax = get_taxonomies(array(), 'objects'); $out = array(); foreach ($tax as $k => $o) { $out[] = array( 'slug' => $k, 'name' => $k, 'label' => $o->label, ); } $addResultText($r, wp_json_encode($out, JSON_PRETTY_PRINT)); break; case 'wp_get_terms': $taxonomy = sanitize_text_field($utils::getArrayValue($args, 'taxonomy')); if (!$taxonomy) { $r['error'] = array('code' => -42602, 'message' => 'taxonomy required'); break; } $terms = get_terms(array('taxonomy' => $taxonomy, 'hide_empty' => false)); $out = array(); foreach ($terms as $t) { $out[] = array('term_id' => $t->term_id, 'name' => $t->name, 'slug' => $t->slug, 'count' => $t->count); } $addResultText($r, wp_json_encode($out, JSON_PRETTY_PRINT)); break; case 'wp_create_term': $taxonomy = sanitize_text_field($utils::getArrayValue($args, 'taxonomy')); $name = sanitize_text_field($utils::getArrayValue($args, 'name')); if (!$taxonomy || !$name) { $r['error'] = array('code' => -42602, 'message' => 'taxonomy & name required'); break; } if (!taxonomy_exists($taxonomy)) { $r['error'] = array('code' => -42600, 'message' => 'Unknown taxonomy: ' . $taxonomy); break; } $tax_obj = get_taxonomy($taxonomy); $cap = $tax_obj && !empty($tax_obj->cap->edit_terms) ? $tax_obj->cap->edit_terms : 'manage_categories'; if (!current_user_can($cap)) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions for taxonomy: ' . $taxonomy); break; } $term_args = array(); if (isset($args['slug'])) { $term_args['slug'] = sanitize_title($args['slug']); } if (isset($args['description'])) { $term_args['description'] = sanitize_textarea_field($args['description']); } if (isset($args['parent'])) { $term_args['parent'] = intval($args['parent']); } $res = wp_insert_term($name, $taxonomy, $term_args); if (is_wp_error($res)) { $r['error'] = array('code' => $res->get_error_code(), 'message' => $res->get_error_message()); } else { $addResultText($r, 'Term created: ' . wp_json_encode($res)); } break; case 'wp_update_term': $term_id = intval($utils::getArrayValue($args, 'term_id')); $taxonomy = sanitize_text_field($utils::getArrayValue($args, 'taxonomy')); if (!$term_id || !$taxonomy) { $r['error'] = array('code' => -42602, 'message' => 'term_id & taxonomy required'); break; } if (!taxonomy_exists($taxonomy)) { $r['error'] = array('code' => -42600, 'message' => 'Unknown taxonomy: ' . $taxonomy); break; } $tax_obj = get_taxonomy($taxonomy); $cap = $tax_obj && !empty($tax_obj->cap->edit_terms) ? $tax_obj->cap->edit_terms : 'manage_categories'; if (!current_user_can($cap)) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions for taxonomy: ' . $taxonomy); break; } $term_args = array(); if (isset($args['name'])) { $term_args['name'] = sanitize_text_field($args['name']); } if (isset($args['slug'])) { $term_args['slug'] = sanitize_title($args['slug']); } if (isset($args['description'])) { $term_args['description'] = sanitize_textarea_field($args['description']); } if (isset($args['parent'])) { $term_args['parent'] = intval($args['parent']); } $res = wp_update_term($term_id, $taxonomy, $term_args); if (is_wp_error($res)) { $r['error'] = array('code' => $res->get_error_code(), 'message' => $res->get_error_message()); } else { $addResultText($r, 'Term updated: ' . wp_json_encode($res)); } break; case 'wp_delete_term': $term_id = intval($utils::getArrayValue($args, 'term_id')); $taxonomy = sanitize_text_field($utils::getArrayValue($args, 'taxonomy')); if (!$term_id || !$taxonomy) { $r['error'] = array('code' => -42602, 'message' => 'term_id & taxonomy required'); break; } if (!taxonomy_exists($taxonomy)) { $r['error'] = array('code' => -42600, 'message' => 'Unknown taxonomy: ' . $taxonomy); break; } $tax_obj = get_taxonomy($taxonomy); $cap = $tax_obj && !empty($tax_obj->cap->delete_terms) ? $tax_obj->cap->delete_terms : 'manage_categories'; if (!current_user_can($cap)) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions for taxonomy: ' . $taxonomy); break; } $done = wp_delete_term($term_id, $taxonomy); if (is_wp_error($done)) { $r['error'] = array('code' => $done->get_error_code(), 'message' => $done->get_error_message()); } else { $addResultText($r, 'Term deleted'); } break; case 'wp_get_term_meta': $term_id = intval($utils::getArrayValue($args, 'term_id')); if (!$term_id) { $r['error'] = array('code' => -42602, 'message' => 'term_id required'); break; } $meta_key = isset($args['meta_key']) ? sanitize_key($args['meta_key']) : ''; $single = !empty($args['single']); if ($meta_key) { $value = get_term_meta($term_id, $meta_key, $single); $value = $utils::redactSecrets($value, $meta_key); $payload = array( 'term_id' => $term_id, 'key' => $meta_key, 'value' => $value, ); } else { $meta = get_term_meta($term_id); $meta = $utils::redactSecrets($meta, ''); $payload = array( 'term_id' => $term_id, 'meta' => $meta, ); } $addResultText($r, wp_json_encode($payload, JSON_PRETTY_PRINT)); break; case 'wp_update_term_meta': $term_id = intval($utils::getArrayValue($args, 'term_id')); $meta_key = isset($args['meta_key']) ? sanitize_key($args['meta_key']) : ''; if (!$term_id || !$meta_key) { $r['error'] = array('code' => -42602, 'message' => 'term_id & meta_key required'); break; } $meta_value = isset($args['meta_value']) ? $args['meta_value'] : ''; $ok = update_term_meta($term_id, $meta_key, $meta_value); if (false === $ok) { $r['error'] = array('code' => -42603, 'message' => 'update_term_meta failed'); } else { $addResultText($r, 'Term meta updated'); } break; case 'wp_delete_term_meta': $term_id = intval($utils::getArrayValue($args, 'term_id')); $meta_key = isset($args['meta_key']) ? sanitize_key($args['meta_key']) : ''; if (!$term_id || !$meta_key) { $r['error'] = array('code' => -42602, 'message' => 'term_id & meta_key required'); break; } if (isset($args['meta_value'])) { $done = delete_term_meta($term_id, $meta_key, $args['meta_value']); } else { $done = delete_term_meta($term_id, $meta_key); } if ($done) { $addResultText($r, 'Term meta deleted'); } else { $r['error'] = array('code' => -42603, 'message' => 'delete_term_meta failed'); } break; // Categories (son terms con taxonomy='category') case 'wp_get_categories': $cargs = array( 'taxonomy' => 'category', 'hide_empty' => isset($args['hide_empty']) ? (bool)$args['hide_empty'] : false, 'number' => max(1, $utils::getArrayValue($args, 'limit', 100, 1)), ); if (isset($args['search'])) { $cargs['search'] = sanitize_text_field($args['search']); } $cats = get_terms($cargs); if (is_wp_error($cats)) { $r['error'] = array('code' => $cats->get_error_code(), 'message' => $cats->get_error_message()); break; } $list = array(); foreach ($cats as $cat) { $list[] = array('term_id' => $cat->term_id, 'name' => $cat->name, 'slug' => $cat->slug, 'count' => $cat->count, 'parent' => $cat->parent); } $addResultText($r, wp_json_encode($list, JSON_PRETTY_PRINT)); break; case 'wp_create_category': if (empty($args['name'])) { $r['error'] = array('code' => -42602, 'message' => 'name required'); break; } $cargs = array('name' => sanitize_text_field($args['name'])); if (isset($args['slug'])) { $cargs['slug'] = sanitize_title($args['slug']); } if (isset($args['parent'])) { $cargs['parent'] = intval($args['parent']); } if (isset($args['description'])) { $cargs['description'] = sanitize_textarea_field($args['description']); } $result = wp_insert_term($cargs['name'], 'category', $cargs); if (is_wp_error($result)) { $r['error'] = array('code' => $result->get_error_code(), 'message' => $result->get_error_message()); } else { $addResultText($r, 'Category created with ID ' . $result['term_id']); } break; case 'wp_update_category': if (empty($args['term_id'])) { $r['error'] = array('code' => -42602, 'message' => 'term_id required'); break; } $cargs = array(); if (isset($args['name'])) { $cargs['name'] = sanitize_text_field($args['name']); } if (isset($args['slug'])) { $cargs['slug'] = sanitize_title($args['slug']); } if (isset($args['parent'])) { $cargs['parent'] = intval($args['parent']); } if (isset($args['description'])) { $cargs['description'] = sanitize_textarea_field($args['description']); } $result = wp_update_term(intval($args['term_id']), 'category', $cargs); if (is_wp_error($result)) { $r['error'] = array('code' => $result->get_error_code(), 'message' => $result->get_error_message()); } else { $addResultText($r, 'Category updated'); } break; case 'wp_delete_category': if (empty($args['term_id'])) { $r['error'] = array('code' => -42602, 'message' => 'term_id required'); break; } $done = wp_delete_term(intval($args['term_id']), 'category'); if (is_wp_error($done)) { $r['error'] = array('code' => $done->get_error_code(), 'message' => $done->get_error_message()); } else { $addResultText($r, 'Category deleted'); } break; // Tags (son terms con taxonomy='post_tag') case 'wp_get_tags': $targs = array( 'taxonomy' => 'post_tag', 'hide_empty' => isset($args['hide_empty']) ? (bool)$args['hide_empty'] : false, 'number' => max(1, $utils::getArrayValue($args, 'limit', 100, 1)), ); if (isset($args['search'])) { $targs['search'] = sanitize_text_field($args['search']); } $tags = get_terms($targs); if (is_wp_error($tags)) { $r['error'] = array('code' => $tags->get_error_code(), 'message' => $tags->get_error_message()); break; } $list = array(); foreach ($tags as $tag) { $list[] = array('term_id' => $tag->term_id, 'name' => $tag->name, 'slug' => $tag->slug, 'count' => $tag->count); } $addResultText($r, wp_json_encode($list, JSON_PRETTY_PRINT)); break; case 'wp_create_tag': if (empty($args['name'])) { $r['error'] = array('code' => -42602, 'message' => 'name required'); break; } $targs = array('name' => sanitize_text_field($args['name'])); if (isset($args['slug'])) { $targs['slug'] = sanitize_title($args['slug']); } if (isset($args['description'])) { $targs['description'] = sanitize_textarea_field($args['description']); } $result = wp_insert_term($targs['name'], 'post_tag', $targs); if (is_wp_error($result)) { $r['error'] = array('code' => $result->get_error_code(), 'message' => $result->get_error_message()); } else { $addResultText($r, 'Tag created with ID ' . $result['term_id']); } break; case 'wp_update_tag': if (empty($args['term_id'])) { $r['error'] = array('code' => -42602, 'message' => 'term_id required'); break; } $targs = array(); if (isset($args['name'])) { $targs['name'] = sanitize_text_field($args['name']); } if (isset($args['slug'])) { $targs['slug'] = sanitize_title($args['slug']); } if (isset($args['description'])) { $targs['description'] = sanitize_textarea_field($args['description']); } $result = wp_update_term(intval($args['term_id']), 'post_tag', $targs); if (is_wp_error($result)) { $r['error'] = array('code' => $result->get_error_code(), 'message' => $result->get_error_message()); } else { $addResultText($r, 'Tag updated'); } break; case 'wp_delete_tag': if (empty($args['term_id'])) { $r['error'] = array('code' => -42602, 'message' => 'term_id required'); break; } $done = wp_delete_term(intval($args['term_id']), 'post_tag'); if (is_wp_error($done)) { $r['error'] = array('code' => $done->get_error_code(), 'message' => $done->get_error_message()); } else { $addResultText($r, 'Tag deleted'); } break; case 'wp_get_nav_menus': case 'wp_get_menus': // Alias $menus = wp_get_nav_menus(); $out = array(); foreach ($menus as $menu) { $out[] = array('term_id' => $menu->term_id, 'name' => $menu->name, 'slug' => $menu->slug); } $addResultText($r, wp_json_encode($out, JSON_PRETTY_PRINT)); break; case 'wp_get_menu': $menu_id = isset($args['menu_id']) ? intval($args['menu_id']) : 0; $menu_location = isset($args['menu_location']) ? sanitize_text_field($args['menu_location']) : ''; if ($menu_location) { $locations = get_nav_menu_locations(); $menu_id = isset($locations[$menu_location]) ? $locations[$menu_location] : 0; } if (!$menu_id) { $r['error'] = array('code' => -42602, 'message' => 'menu_id or menu_location required'); break; } $menu = wp_get_nav_menu_object($menu_id); if (!$menu) { $r['error'] = array('code' => -42600, 'message' => 'Menu not found'); break; } $items = wp_get_nav_menu_items($menu_id); $menu_items = array(); if ($items) { foreach ($items as $item) { $menu_items[] = array( 'ID' => $item->ID, 'title' => $item->title, 'url' => $item->url, 'menu_order' => $item->menu_order, 'parent' => $item->menu_item_parent, 'type' => $item->type, 'object' => $item->object, 'object_id' => $item->object_id, ); } } $out = array('term_id' => $menu->term_id, 'name' => $menu->name, 'slug' => $menu->slug, 'items' => $menu_items); $addResultText($r, wp_json_encode($out, JSON_PRETTY_PRINT)); break; case 'wp_create_nav_menu': if (empty($args['menu_name'])) { $r['error'] = array('code' => -42602, 'message' => 'menu_name required'); break; } $menu_id = wp_create_nav_menu(sanitize_text_field($args['menu_name'])); if (is_wp_error($menu_id)) { $r['error'] = array('code' => $menu_id->get_error_code(), 'message' => $menu_id->get_error_message()); } else { $addResultText($r, 'Navigation menu created with ID ' . $menu_id); } break; case 'wp_add_nav_menu_item': if (empty($args['menu_id']) || empty($args['menu_item_title']) || empty($args['menu_item_type'])) { $r['error'] = array('code' => -42602, 'message' => 'menu_id, menu_item_title, menu_item_type required'); break; } $item = array( 'menu-item-title' => sanitize_text_field($args['menu_item_title']), 'menu-item-type' => sanitize_key($args['menu_item_type']), 'menu-item-object' => isset($args['menu_item_object']) ? sanitize_key($args['menu_item_object']) : '', 'menu-item-object-id' => isset($args['menu_item_object_id']) ? intval($args['menu_item_object_id']) : 0, 'menu-item-url' => isset($args['menu_item_url']) ? esc_url_raw($args['menu_item_url']) : '', 'menu-item-parent-id' => isset($args['menu_item_parent_id']) ? intval($args['menu_item_parent_id']) : 0, 'menu-item-status' => 'publish', ); $item_id = wp_update_nav_menu_item(intval($args['menu_id']), 0, $item); if (is_wp_error($item_id)) { $r['error'] = array('code' => $item_id->get_error_code(), 'message' => $item_id->get_error_message()); } else { $addResultText($r, 'Menu item added with ID ' . $item_id . ' to menu ' . $args['menu_id']); } break; case 'wp_update_nav_menu_item': if (empty($args['menu_id']) || empty($args['menu_item_id'])) { $r['error'] = array('code' => -42602, 'message' => 'menu_id & menu_item_id required'); break; } $existing_post = get_post(intval($args['menu_item_id'])); if (!$existing_post || 'nav_menu_item' !== $existing_post->post_type) { $r['error'] = array('code' => -42600, 'message' => 'Menu item not found'); break; } $existing_item = wp_setup_nav_menu_item($existing_post); if (!$existing_item || intval($existing_item->menu_item_parent) < 0) { $r['error'] = array('code' => -42600, 'message' => 'Unable to load menu item'); break; } $item = array( 'menu-item-db-id' => intval($existing_item->ID), 'menu-item-object-id' => intval($existing_item->object_id), 'menu-item-object' => isset($existing_item->object) ? sanitize_key($existing_item->object) : '', 'menu-item-parent-id' => intval($existing_item->menu_item_parent), 'menu-item-position' => intval($existing_item->menu_order), 'menu-item-type' => isset($existing_item->type) ? sanitize_key($existing_item->type) : '', 'menu-item-title' => isset($existing_item->title) ? sanitize_text_field($existing_item->title) : '', 'menu-item-url' => isset($existing_item->url) ? esc_url_raw($existing_item->url) : '', 'menu-item-description' => isset($existing_item->description) ? sanitize_text_field($existing_item->description) : '', 'menu-item-attr-title' => isset($existing_item->attr_title) ? sanitize_text_field($existing_item->attr_title) : '', 'menu-item-target' => isset($existing_item->target) ? sanitize_text_field($existing_item->target) : '', 'menu-item-classes' => isset($existing_item->classes) && is_array($existing_item->classes) ? implode(' ', array_map('sanitize_html_class', $existing_item->classes)) : '', 'menu-item-xfn' => isset($existing_item->xfn) ? sanitize_text_field($existing_item->xfn) : '', 'menu-item-status' => !empty($existing_post->post_status) ? sanitize_key($existing_post->post_status) : 'publish', ); $menu_update_error = null; if (!empty($args['fields']) && is_array($args['fields'])) { foreach ($args['fields'] as $k => $v) { switch ((string) $k) { case 'title': $new_title = sanitize_text_field($v); if ('' === $new_title && '' !== trim((string) $existing_item->title)) { $menu_update_error = array('code' => -42602, 'message' => 'Refusing to clear a non-empty menu item title. Delete and recreate the item if you intend to blank it.'); break 2; } $item['menu-item-title'] = $new_title; break; case 'url': $new_url = esc_url_raw($v); if ('' === $new_url && '' !== trim((string) $existing_item->url)) { $menu_update_error = array('code' => -42602, 'message' => 'Refusing to clear a non-empty menu item URL. Delete and recreate the item if you intend to blank it.'); break 2; } $item['menu-item-url'] = $new_url; break; case 'parent_id': $item['menu-item-parent-id'] = intval($v); break; case 'menu_order': case 'position': $item['menu-item-position'] = intval($v); break; case 'object': $item['menu-item-object'] = sanitize_key($v); break; case 'object_id': $item['menu-item-object-id'] = intval($v); break; case 'type': $item['menu-item-type'] = sanitize_key($v); break; case 'description': $item['menu-item-description'] = sanitize_text_field($v); break; case 'attr_title': $item['menu-item-attr-title'] = sanitize_text_field($v); break; case 'target': $item['menu-item-target'] = sanitize_text_field($v); break; case 'xfn': $item['menu-item-xfn'] = sanitize_text_field($v); break; case 'classes': if (is_array($v)) { $item['menu-item-classes'] = implode(' ', array_map('sanitize_html_class', $v)); } else { $item['menu-item-classes'] = sanitize_text_field($v); } break; } } } if (!empty($menu_update_error)) { $r['error'] = $menu_update_error; break; } $item_id = wp_update_nav_menu_item(intval($args['menu_id']), intval($args['menu_item_id']), $item); if (is_wp_error($item_id)) { $r['error'] = array('code' => $item_id->get_error_code(), 'message' => $item_id->get_error_message()); } else { $addResultText($r, 'Menu item #' . $args['menu_item_id'] . ' updated in menu ' . $args['menu_id']); } break; case 'wp_delete_nav_menu_item': if (empty($args['menu_item_id'])) { $r['error'] = array('code' => -42602, 'message' => 'menu_item_id required'); break; } $deleted = wp_delete_post(intval($args['menu_item_id']), true); if ($deleted) { $addResultText($r, 'Menu item #' . $args['menu_item_id'] . ' deleted'); } else { $r['error'] = array('code' => -42603, 'message' => 'Deletion failed'); } break; case 'wp_delete_nav_menu': if (empty($args['menu_id'])) { $r['error'] = array('code' => -42602, 'message' => 'menu_id required'); break; } $deleted = wp_delete_nav_menu(intval($args['menu_id'])); if (is_wp_error($deleted)) { $r['error'] = array('code' => $deleted->get_error_code(), 'message' => $deleted->get_error_message()); } else { $addResultText($r, 'Navigation menu #' . $args['menu_id'] . ' deleted'); } break; case 'wp_reorder_menu_items': $menu_id = intval($utils::getArrayValue($args, 'menu_id', 0)); $items = $utils::getArrayValue($args, 'items', array()); if (!$menu_id || !is_array($items) || empty($items)) { $r['error'] = array('code' => -42602, 'message' => 'menu_id and items[] required'); break; } $menu_obj = wp_get_nav_menu_object($menu_id); if (!$menu_obj) { $r['error'] = array('code' => -42600, 'message' => 'Menu not found'); break; } if (!current_user_can('edit_theme_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'Insufficient permissions to edit menus'); break; } $existing = wp_get_nav_menu_items($menu_id); $existing_ids = array(); if (is_array($existing)) { foreach ($existing as $ex) { $existing_ids[(int)$ex->ID] = true; } } $reorder_results = array(); foreach ($items as $entry) { if (!is_array($entry) || empty($entry['item_id'])) { continue; } $item_id = intval($entry['item_id']); if (!isset($existing_ids[$item_id])) { $reorder_results[] = array('item_id' => $item_id, 'status' => 'skipped', 'reason' => 'not in this menu'); continue; } $upd = array( 'ID' => $item_id, 'menu_order' => isset($entry['menu_order']) ? intval($entry['menu_order']) : 0, ); if (array_key_exists('parent_id', $entry)) { $upd['post_parent'] = intval($entry['parent_id']); update_post_meta($item_id, '_menu_item_menu_item_parent', (string) intval($entry['parent_id'])); } $res = wp_update_post($upd, true); if (is_wp_error($res)) { $reorder_results[] = array('item_id' => $item_id, 'status' => 'error', 'message' => $res->get_error_message()); } else { $reorder_results[] = array('item_id' => $item_id, 'status' => 'ok'); } } $addResultText($r, wp_json_encode(array('menu_id' => $menu_id, 'results' => $reorder_results), JSON_PRETTY_PRINT)); break; case 'wp_generate_image': stifli_flex_mcp_log('wp_generate_image: === START ==='); // Keep processing even if the MCP client disconnects/retries. if ( function_exists( 'ignore_user_abort' ) ) { ignore_user_abort( true ); } // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged,WordPress.PHP.NoSilencedErrors.Discouraged -- required for long-running media generation on unstable client transports. @set_time_limit( 0 ); $prompt = sanitize_text_field( $utils::getArrayValue( $args, 'prompt', '' ) ); if ( empty( $prompt ) ) { stifli_flex_mcp_log('wp_generate_image: ERROR - prompt is empty'); $r['error'] = array( 'code' => -42602, 'message' => 'prompt required' ); break; } if ( ! current_user_can( 'upload_files' ) ) { stifli_flex_mcp_log('wp_generate_image: ERROR - user lacks upload_files capability'); $r['error'] = array( 'code' => 'permission_denied', 'message' => 'Insufficient permissions to upload files' ); break; } $img_size = sanitize_text_field( $utils::getArrayValue( $args, 'size', 'square' ) ); $img_quality = sanitize_text_field( $utils::getArrayValue( $args, 'quality', 'medium' ) ); $img_alt = sanitize_text_field( $utils::getArrayValue( $args, 'alt_text', '' ) ); $img_title = sanitize_text_field( $utils::getArrayValue( $args, 'title', '' ) ); $img_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); stifli_flex_mcp_log('wp_generate_image: prompt="' . substr( $prompt, 0, 120 ) . '" size=' . $img_size . ' quality=' . $img_quality . ' post_id=' . $img_post_id); // Load multimedia settings (dedicated — independent from Chat Agent) $mm_settings = get_option( 'sflmcp_multimedia_settings', array() ); $provider = ! empty( $mm_settings['image_provider'] ) ? $mm_settings['image_provider'] : 'openai'; stifli_flex_mcp_log('wp_generate_image: provider=' . $provider); // Resolve API key: multimedia settings only (no Chat Agent fallback) $encrypted_key = ''; if ( $provider === 'gemini' ) { $encrypted_key = ! empty( $mm_settings['gemini_api_key'] ) ? $mm_settings['gemini_api_key'] : ''; } else { $encrypted_key = ! empty( $mm_settings['openai_api_key'] ) ? $mm_settings['openai_api_key'] : ''; } // Decrypt API key (same logic as StifliFlexMcp_Client_Admin) $api_key = ''; if ( ! empty( $encrypted_key ) ) { if ( class_exists( 'StifliFlexMcp_Client_Admin' ) ) { $api_key = StifliFlexMcp_Client_Admin::decrypt_value( $encrypted_key ); } else { $api_key = $encrypted_key; // fallback: may already be plain } } if ( empty( $api_key ) ) { stifli_flex_mcp_log('wp_generate_image: ERROR - no API key configured for provider=' . $provider); $r['error'] = array( 'code' => -32603, 'message' => 'No AI API key configured. Go to StifLi Flex MCP > Multimedia Settings to set one.' ); break; } stifli_flex_mcp_log('wp_generate_image: API key resolved (length=' . strlen( $api_key ) . ')'); $image_binary = false; $mime_type = 'image/png'; $gen_error = ''; if ( $provider === 'gemini' ) { // --- Gemini image generation --- $gemini_model = ! empty( $mm_settings['gemini_model'] ) ? $mm_settings['gemini_model'] : 'gemini-2.5-flash-image'; $default_ratio = ! empty( $mm_settings['gemini_aspect_ratio'] ) ? $mm_settings['gemini_aspect_ratio'] : '1:1'; // Map size to Gemini aspect ratio $aspect_map = array( 'square' => '1:1', 'landscape' => '16:9', 'portrait' => '9:16', 'wide' => '21:9', ); $valid_ratios = array( '1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9' ); $aspect_ratio = isset( $aspect_map[ $img_size ] ) ? $aspect_map[ $img_size ] : ( in_array( $img_size, $valid_ratios, true ) ? $img_size : $default_ratio ); // Imagen models use a different API (generateImages) vs Gemini flash (generateContent) $is_imagen = ( strpos( $gemini_model, 'imagen' ) === 0 ); stifli_flex_mcp_log('wp_generate_image: Gemini model=' . $gemini_model . ' is_imagen=' . ( $is_imagen ? 'yes' : 'no' ) . ' aspect_ratio=' . $aspect_ratio); if ( $is_imagen ) { // --- Imagen 4 API --- $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $gemini_model . ':generateImages?key=' . $api_key; stifli_flex_mcp_log('wp_generate_image: Calling Imagen API...'); $body = array( 'prompt' => $prompt, 'config' => array( 'numberOfImages' => 1, 'aspectRatio' => $aspect_ratio, 'outputOptions' => array( 'mimeType' => 'image/png', ), ), ); $resp = wp_remote_post( $api_url, array( 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'timeout' => 120, ) ); if ( is_wp_error( $resp ) ) { $gen_error = 'Imagen API error: ' . $resp->get_error_message(); stifli_flex_mcp_log('wp_generate_image: Imagen WP error: ' . $gen_error); } else { $http_code = wp_remote_retrieve_response_code( $resp ); $resp_body = json_decode( wp_remote_retrieve_body( $resp ), true ); stifli_flex_mcp_log('wp_generate_image: Imagen response HTTP ' . $http_code); if ( 200 !== $http_code ) { $gen_error = 'Imagen API error (HTTP ' . $http_code . '): ' . ( isset( $resp_body['error']['message'] ) ? $resp_body['error']['message'] : 'Unknown error' ); stifli_flex_mcp_log('wp_generate_image: ' . $gen_error); } else { $b64_data = ''; if ( isset( $resp_body['generatedImages'][0]['image']['imageBytes'] ) ) { $b64_data = $resp_body['generatedImages'][0]['image']['imageBytes']; $mime_type = 'image/png'; } if ( empty( $b64_data ) ) { $gen_error = 'Imagen returned no image data.'; } else { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding AI-generated image binary. $image_binary = base64_decode( $b64_data ); if ( false === $image_binary ) { $gen_error = 'Failed to decode Imagen base64 image data.'; $image_binary = false; } } } } } else { // --- Gemini Flash generateContent API --- $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $gemini_model . ':generateContent?key=' . $api_key; stifli_flex_mcp_log('wp_generate_image: Calling Gemini generateContent API...'); $body = array( 'contents' => array( array( 'parts' => array( array( 'text' => $prompt ), ), ), ), 'generationConfig' => array( 'responseModalities' => array( 'IMAGE', 'TEXT' ), 'imageConfig' => array( 'aspectRatio' => $aspect_ratio, ), ), ); $resp = wp_remote_post( $api_url, array( 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'timeout' => 120, ) ); if ( is_wp_error( $resp ) ) { $gen_error = 'Gemini API error: ' . $resp->get_error_message(); stifli_flex_mcp_log('wp_generate_image: Gemini WP error: ' . $gen_error); } else { $http_code = wp_remote_retrieve_response_code( $resp ); $resp_body = json_decode( wp_remote_retrieve_body( $resp ), true ); stifli_flex_mcp_log('wp_generate_image: Gemini response HTTP ' . $http_code); if ( 200 !== $http_code ) { $gen_error = 'Gemini API error (HTTP ' . $http_code . '): ' . ( isset( $resp_body['error']['message'] ) ? $resp_body['error']['message'] : 'Unknown error' ); stifli_flex_mcp_log('wp_generate_image: ' . $gen_error); } else { // Extract image from response $parts_arr = isset( $resp_body['candidates'][0]['content']['parts'] ) ? $resp_body['candidates'][0]['content']['parts'] : array(); $b64_data = ''; foreach ( $parts_arr as $part ) { if ( isset( $part['inlineData']['data'] ) ) { $b64_data = $part['inlineData']['data']; $mime_type = isset( $part['inlineData']['mimeType'] ) ? $part['inlineData']['mimeType'] : 'image/png'; break; } elseif ( isset( $part['inline_data']['data'] ) ) { $b64_data = $part['inline_data']['data']; $mime_type = isset( $part['inline_data']['mime_type'] ) ? $part['inline_data']['mime_type'] : 'image/png'; break; } } if ( empty( $b64_data ) ) { $finish = isset( $resp_body['candidates'][0]['finishReason'] ) ? $resp_body['candidates'][0]['finishReason'] : 'UNKNOWN'; if ( in_array( $finish, array( 'IMAGE_SAFETY', 'IMAGE_PROHIBITED_CONTENT' ), true ) ) { $gen_error = 'Gemini blocked image generation due to safety filters (reason: ' . $finish . ').'; } else { // Retry once with reinforced prompt $retry_prompt = 'Generate an image based on this description (you MUST return an image, not text): ' . $prompt; $body['contents'][0]['parts'][0]['text'] = $retry_prompt; $resp2 = wp_remote_post( $api_url, array( 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $body ), 'timeout' => 120, ) ); if ( ! is_wp_error( $resp2 ) && 200 === wp_remote_retrieve_response_code( $resp2 ) ) { $resp_body2 = json_decode( wp_remote_retrieve_body( $resp2 ), true ); $parts2 = isset( $resp_body2['candidates'][0]['content']['parts'] ) ? $resp_body2['candidates'][0]['content']['parts'] : array(); foreach ( $parts2 as $part ) { if ( isset( $part['inlineData']['data'] ) ) { $b64_data = $part['inlineData']['data']; $mime_type = isset( $part['inlineData']['mimeType'] ) ? $part['inlineData']['mimeType'] : 'image/png'; break; } elseif ( isset( $part['inline_data']['data'] ) ) { $b64_data = $part['inline_data']['data']; $mime_type = isset( $part['inline_data']['mime_type'] ) ? $part['inline_data']['mime_type'] : 'image/png'; break; } } } if ( empty( $b64_data ) ) { $gen_error = 'Gemini returned no image data after retry (finishReason: ' . $finish . ').'; } } } if ( ! empty( $b64_data ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding AI-generated image binary. $image_binary = base64_decode( $b64_data ); if ( false === $image_binary ) { $gen_error = 'Failed to decode Gemini base64 image data.'; $image_binary = false; } } } } } // end Gemini flash else } else { // --- OpenAI image generation (configurable model with DALL-E fallback) --- stifli_flex_mcp_log('wp_generate_image: Using OpenAI provider'); $oai_model = ! empty( $mm_settings['openai_model'] ) ? $mm_settings['openai_model'] : 'gpt-image-1'; $default_size = ! empty( $mm_settings['openai_size'] ) ? $mm_settings['openai_size'] : 'square'; $default_qual = ! empty( $mm_settings['openai_quality'] ) ? $mm_settings['openai_quality'] : 'medium'; $oai_style = ! empty( $mm_settings['openai_style'] ) ? $mm_settings['openai_style'] : 'natural'; $oai_bg = ! empty( $mm_settings['openai_background'] ) ? $mm_settings['openai_background'] : 'auto'; $oai_out_format = ! empty( $mm_settings['openai_output_format'] ) ? $mm_settings['openai_output_format'] : 'png'; // Use tool arg if provided, otherwise use settings default $effective_size = ( $img_size !== 'square' || ! empty( $args['size'] ) ) ? $img_size : $default_size; $effective_quality = ( $img_quality !== 'medium' || ! empty( $args['quality'] ) ) ? $img_quality : $default_qual; $size_map = array( 'square' => '1024x1024', 'landscape' => '1536x1024', 'portrait' => '1024x1536', ); $oai_size = isset( $size_map[ $effective_size ] ) ? $size_map[ $effective_size ] : '1024x1024'; $quality_map = array( 'low' => 'low', 'medium' => 'medium', 'high' => 'high', 'standard' => 'medium', 'hd' => 'high', ); $oai_quality = isset( $quality_map[ $effective_quality ] ) ? $quality_map[ $effective_quality ] : 'medium'; $oai_body = array( 'prompt' => $prompt, 'n' => 1, 'size' => $oai_size, 'model' => $oai_model, 'quality' => $oai_quality, ); // Add model-specific parameters $is_gpt_image_model = ( strpos( $oai_model, 'gpt-image-' ) === 0 ); if ( $is_gpt_image_model ) { $oai_body['output_format'] = $oai_out_format; if ( $oai_bg !== 'auto' ) { // gpt-image-2 does not support transparent background. if ( $oai_model === 'gpt-image-2' && $oai_bg === 'transparent' ) { stifli_flex_mcp_log('wp_generate_image: transparent background is not supported by gpt-image-2, using auto'); } else { $oai_body['background'] = $oai_bg; } } } elseif ( $oai_model === 'dall-e-3' ) { $oai_body['style'] = $oai_style; // DALL-E 3 only supports specific sizes $dalle3_sizes = array( '1024x1024', '1792x1024', '1024x1792' ); if ( ! in_array( $oai_size, $dalle3_sizes, true ) ) { $dalle3_remap = array( '1536x1024' => '1792x1024', '1024x1536' => '1024x1792' ); $oai_body['size'] = isset( $dalle3_remap[ $oai_size ] ) ? $dalle3_remap[ $oai_size ] : '1024x1024'; } // DALL-E 3 uses standard/hd quality $dalle3_qual = array( 'high' => 'hd', 'medium' => 'standard', 'low' => 'standard' ); $oai_body['quality'] = isset( $dalle3_qual[ $oai_quality ] ) ? $dalle3_qual[ $oai_quality ] : 'standard'; } stifli_flex_mcp_log('wp_generate_image: OpenAI model=' . $oai_body['model'] . ' size=' . $oai_body['size'] . ' quality=' . $oai_body['quality']); $oai_resp = wp_remote_post( 'https://api.openai.com/v1/images/generations', array( 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $oai_body ), 'timeout' => 120, ) ); $oai_ok = false; $oai_data = array(); if ( is_wp_error( $oai_resp ) ) { $gen_error = 'OpenAI API error: ' . $oai_resp->get_error_message(); stifli_flex_mcp_log('wp_generate_image: OpenAI WP error: ' . $gen_error); } else { $oai_http = wp_remote_retrieve_response_code( $oai_resp ); $oai_json = json_decode( wp_remote_retrieve_body( $oai_resp ), true ); // Fallback to DALL-E 3 if GPT Image requires verification $must_verify = ( 403 === $oai_http ) && isset( $oai_json['error']['message'] ) && stripos( $oai_json['error']['message'], 'must be verified' ) !== false; if ( $must_verify && $oai_model !== 'dall-e-3' ) { $dalle3_size_map = array( '1536x1024' => '1792x1024', '1024x1536' => '1024x1792', '1024x1024' => '1024x1024', ); $dalle3_quality_map = array( 'high' => 'hd', 'medium' => 'standard', 'low' => 'standard', ); $oai_body['model'] = 'dall-e-3'; $oai_body['size'] = isset( $dalle3_size_map[ $oai_size ] ) ? $dalle3_size_map[ $oai_size ] : '1024x1024'; $oai_body['quality'] = isset( $dalle3_quality_map[ $oai_quality ] ) ? $dalle3_quality_map[ $oai_quality ] : 'standard'; $oai_body['style'] = $oai_style; unset( $oai_body['output_format'], $oai_body['background'] ); $oai_resp = wp_remote_post( 'https://api.openai.com/v1/images/generations', array( 'headers' => array( 'Authorization' => 'Bearer ' . $api_key, 'Content-Type' => 'application/json', ), 'body' => wp_json_encode( $oai_body ), 'timeout' => 120, ) ); if ( ! is_wp_error( $oai_resp ) ) { $oai_http = wp_remote_retrieve_response_code( $oai_resp ); $oai_json = json_decode( wp_remote_retrieve_body( $oai_resp ), true ); } else { $gen_error = 'OpenAI DALL-E 3 fallback error: ' . $oai_resp->get_error_message(); } } if ( empty( $gen_error ) ) { if ( 200 !== $oai_http || ! is_array( $oai_json ) ) { $err_msg = isset( $oai_json['error']['message'] ) ? $oai_json['error']['message'] : 'Unknown error'; $gen_error = 'OpenAI API error (HTTP ' . $oai_http . '): ' . $err_msg; } else { $oai_data = isset( $oai_json['data'][0] ) ? $oai_json['data'][0] : array(); $oai_ok = true; } } } if ( $oai_ok ) { if ( ! empty( $oai_data['b64_json'] ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding AI-generated image binary. $image_binary = base64_decode( $oai_data['b64_json'] ); $mime_map = array( 'png' => 'image/png', 'jpeg' => 'image/jpeg', 'webp' => 'image/webp', ); $fmt = isset( $oai_body['output_format'] ) ? $oai_body['output_format'] : 'png'; $mime_type = isset( $mime_map[ $fmt ] ) ? $mime_map[ $fmt ] : 'image/png'; } elseif ( ! empty( $oai_data['url'] ) ) { // DALL-E 3 returns a URL — download it $dl = wp_remote_get( $oai_data['url'], array( 'timeout' => 60 ) ); if ( ! is_wp_error( $dl ) && 200 === wp_remote_retrieve_response_code( $dl ) ) { $image_binary = wp_remote_retrieve_body( $dl ); $ct = wp_remote_retrieve_header( $dl, 'content-type' ); if ( stripos( $ct, 'jpeg' ) !== false || stripos( $ct, 'jpg' ) !== false ) { $mime_type = 'image/jpeg'; } elseif ( stripos( $ct, 'webp' ) !== false ) { $mime_type = 'image/webp'; } else { $mime_type = 'image/png'; } } else { $gen_error = 'Failed to download generated image from OpenAI.'; } } else { $gen_error = 'OpenAI response contained no image data (no b64_json or url).'; } } } // Handle generation error if ( false === $image_binary || empty( $image_binary ) ) { stifli_flex_mcp_log('wp_generate_image: FAILED - ' . ( $gen_error ? $gen_error : 'unknown error' )); $r['error'] = array( 'code' => -32603, 'message' => $gen_error ? $gen_error : 'Image generation failed.' ); break; } stifli_flex_mcp_log('wp_generate_image: Image binary received, size=' . strlen( $image_binary ) . ' bytes, mime=' . $mime_type); // Save as WordPress media attachment. Avoid media_handle_sideload() // here because it generates image metadata/subsizes synchronously. if ( ! function_exists( 'wp_upload_bits' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $ext_map = array( 'image/png' => 'png', 'image/jpeg' => 'jpg', 'image/webp' => 'webp' ); $ext = isset( $ext_map[ $mime_type ] ) ? $ext_map[ $mime_type ] : 'png'; $filename = 'ai-generated-' . gmdate( 'Ymd-His' ) . '-' . wp_generate_password( 6, false ) . '.' . $ext; stifli_flex_mcp_log('wp_generate_image: Writing image to uploads as ' . $filename); $upload = wp_upload_bits( $filename, null, $image_binary ); unset( $image_binary ); if ( ! empty( $upload['error'] ) ) { stifli_flex_mcp_log('wp_generate_image: Upload bits error: ' . $upload['error']); $r['error'] = array( 'code' => 'write_error', 'message' => 'Failed to write image file: ' . $upload['error'] ); break; } if ( empty( $upload['file'] ) || ! file_exists( $upload['file'] ) ) { stifli_flex_mcp_log('wp_generate_image: Upload bits returned no readable file'); $r['error'] = array( 'code' => 'write_error', 'message' => 'Failed to create image file in uploads.' ); break; } stifli_flex_mcp_log('wp_generate_image: Upload file ready path=' . $upload['file']); $filetype = wp_check_filetype( $upload['file'], null ); $attachment = array( 'guid' => $upload['url'], 'post_mime_type' => ! empty( $filetype['type'] ) ? $filetype['type'] : $mime_type, 'post_title' => $img_title ? $img_title : preg_replace( '/\.[^.]+$/', '', basename( $filename ) ), 'post_content' => '', 'post_status' => 'inherit', ); $att_id = wp_insert_attachment( $attachment, $upload['file'], $img_post_id, true ); if ( is_wp_error( $att_id ) ) { stifli_flex_mcp_log('wp_generate_image: Attachment insert error: ' . $att_id->get_error_message()); wp_delete_file( $upload['file'] ); $r['error'] = array( 'code' => 'upload_error', 'message' => $att_id->get_error_message() ); break; } $att_id = (int) $att_id; update_attached_file( $att_id, $upload['file'] ); stifli_flex_mcp_log('wp_generate_image: Saved as attachment ID=' . $att_id . ' file=' . $upload['file']); $pp_enabled = ! empty( $mm_settings['pp_enabled'] ) && '1' === $mm_settings['pp_enabled']; $post_process_scheduled = false; if ( ! wp_next_scheduled( 'sflmcp_process_generated_image_attachment', array( $att_id ) ) ) { $schedule_result = wp_schedule_single_event( time() + 1, 'sflmcp_process_generated_image_attachment', array( $att_id ) ); if ( is_wp_error( $schedule_result ) ) { stifli_flex_mcp_log('wp_generate_image: Failed to schedule metadata/post-processing for ID=' . $att_id . ' message=' . $schedule_result->get_error_message()); } else { $post_process_scheduled = ( false !== $schedule_result ); } if ( $post_process_scheduled && function_exists( 'spawn_cron' ) ) { spawn_cron( time() ); } stifli_flex_mcp_log('wp_generate_image: Scheduled attachment metadata/post-processing for ID=' . $att_id . ' scheduled=' . ( $post_process_scheduled ? 'yes' : 'no' )); } else { $post_process_scheduled = true; stifli_flex_mcp_log('wp_generate_image: Attachment metadata/post-processing already scheduled for ID=' . $att_id); } // Set alt text and title if ( $img_alt ) { update_post_meta( $att_id, '_wp_attachment_image_alt', $img_alt ); } if ( $img_title ) { wp_update_post( array( 'ID' => $att_id, 'post_title' => $img_title ) ); } $att_url = wp_get_attachment_url( $att_id ); $medium_url = ''; $medium_arr = wp_get_attachment_image_src( $att_id, 'medium' ); if ( $medium_arr ) { $medium_url = $medium_arr[0]; } $result_data = array( 'attachment_id' => $att_id, 'url' => $att_url, 'medium_url' => $medium_url ? $medium_url : $att_url, 'provider' => $provider, 'model' => $provider === 'gemini' ? ( isset( $gemini_model ) ? $gemini_model : 'gemini' ) : ( isset( $oai_body['model'] ) ? $oai_body['model'] : 'openai' ), 'post_processed' => false, 'post_process_scheduled' => $post_process_scheduled, 'prompt' => $prompt, ); stifli_flex_mcp_log('wp_generate_image: === SUCCESS === attachment_id=' . $att_id . ' url=' . $att_url . ' provider=' . $provider . ' post_process_scheduled=' . ( $post_process_scheduled ? 'yes' : 'no' ) . ' pp_enabled=' . ( $pp_enabled ? 'yes' : 'no' )); $addResultText( $r, wp_json_encode( $result_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); // Also add an image content block for MCP clients that support it $r['result']['content'][] = array( 'type' => 'image', 'data' => $medium_url ? $medium_url : $att_url, 'mimeType' => $mime_type, ); stifli_flex_mcp_log('wp_generate_image: === END ==='); break; case 'wp_generate_video': stifli_flex_mcp_log('wp_generate_video: === START ==='); // Ensure PHP doesn't kill us during long generation + polling. // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- set_time_limit required for video generation (60-300s). @set_time_limit( 0 ); ignore_user_abort( true ); $vid_prompt = sanitize_text_field( $utils::getArrayValue( $args, 'prompt', '' ) ); if ( empty( $vid_prompt ) ) { stifli_flex_mcp_log('wp_generate_video: ERROR - prompt is empty'); $r['error'] = array( 'code' => -42602, 'message' => 'prompt required' ); break; } if ( ! current_user_can( 'upload_files' ) ) { stifli_flex_mcp_log('wp_generate_video: ERROR - user lacks upload_files capability'); $r['error'] = array( 'code' => 'permission_denied', 'message' => 'Insufficient permissions to upload files' ); break; } // Load multimedia settings $vid_mm = get_option( 'sflmcp_multimedia_settings', array() ); $vid_provider = ! empty( $vid_mm['video_provider'] ) ? $vid_mm['video_provider'] : 'gemini'; $vid_duration = sanitize_text_field( $utils::getArrayValue( $args, 'duration', '' ) ); if ( empty( $vid_duration ) ) { $vid_duration = ! empty( $vid_mm['video_duration'] ) ? $vid_mm['video_duration'] : '5'; } $vid_aspect = sanitize_text_field( $utils::getArrayValue( $args, 'aspect_ratio', '' ) ); if ( empty( $vid_aspect ) ) { $vid_aspect = ! empty( $vid_mm['video_aspect_ratio'] ) ? $vid_mm['video_aspect_ratio'] : '16:9'; } $vid_title = sanitize_text_field( $utils::getArrayValue( $args, 'title', '' ) ); $vid_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); $vid_poll = intval( ! empty( $vid_mm['video_poll_interval'] ) ? $vid_mm['video_poll_interval'] : 10 ); $vid_max_wait = intval( ! empty( $vid_mm['video_max_wait'] ) ? $vid_mm['video_max_wait'] : 300 ); stifli_flex_mcp_log('wp_generate_video: prompt="' . substr( $vid_prompt, 0, 120 ) . '" provider=' . $vid_provider . ' duration=' . $vid_duration . 's aspect=' . $vid_aspect . ' poll=' . $vid_poll . 's max_wait=' . $vid_max_wait . 's'); // ── Resolve optional reference images (source frame + end frame) ── $vid_image_url = sanitize_text_field( $utils::getArrayValue( $args, 'image_url', '' ) ); $vid_image_end_url = sanitize_text_field( $utils::getArrayValue( $args, 'image_end_url', '' ) ); /** * Helper: resolve an image reference to base64 + mime. * Accepts a URL (http/https) or a numeric WP attachment ID. * Returns array('data' => base64_string, 'mime' => 'image/jpeg') or null on failure. */ $resolveImageToBase64 = function ( $ref ) { if ( empty( $ref ) ) { return null; } $binary = false; $img_mime = 'image/jpeg'; if ( is_numeric( $ref ) ) { // WordPress attachment ID $file = get_attached_file( intval( $ref ) ); if ( $file && file_exists( $file ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading local WP attachment for API upload. $binary = file_get_contents( $file ); $img_mime = wp_check_filetype( $file )['type'] ?: 'image/jpeg'; } } elseif ( filter_var( $ref, FILTER_VALIDATE_URL ) ) { // External URL $dl = wp_remote_get( $ref, array( 'timeout' => 30 ) ); if ( ! is_wp_error( $dl ) && 200 === wp_remote_retrieve_response_code( $dl ) ) { $binary = wp_remote_retrieve_body( $dl ); $ct = wp_remote_retrieve_header( $dl, 'content-type' ); if ( stripos( $ct, 'png' ) !== false ) { $img_mime = 'image/png'; } elseif ( stripos( $ct, 'webp' ) !== false ) { $img_mime = 'image/webp'; } } } if ( false === $binary || empty( $binary ) ) { return null; } // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- encoding image for AI API. return array( 'data' => base64_encode( $binary ), 'mime' => $img_mime ); }; $vid_src_image = $resolveImageToBase64( $vid_image_url ); $vid_end_image = $resolveImageToBase64( $vid_image_end_url ); if ( $vid_src_image ) { stifli_flex_mcp_log('wp_generate_video: Source image resolved, mime=' . $vid_src_image['mime'] . ' base64_len=' . strlen( $vid_src_image['data'] )); } if ( $vid_end_image ) { stifli_flex_mcp_log('wp_generate_video: End image resolved, mime=' . $vid_end_image['mime'] . ' base64_len=' . strlen( $vid_end_image['data'] )); } // Resolve API key — shared key from multimedia settings (no separate video keys) $vid_encrypted_key = ''; if ( 'gemini' === $vid_provider ) { $vid_encrypted_key = ! empty( $vid_mm['gemini_api_key'] ) ? $vid_mm['gemini_api_key'] : ''; } else { $vid_encrypted_key = ! empty( $vid_mm['openai_api_key'] ) ? $vid_mm['openai_api_key'] : ''; } $vid_api_key = ''; if ( ! empty( $vid_encrypted_key ) ) { if ( class_exists( 'StifliFlexMcp_Client_Admin' ) ) { $vid_api_key = StifliFlexMcp_Client_Admin::decrypt_value( $vid_encrypted_key ); } else { $vid_api_key = $vid_encrypted_key; } } if ( empty( $vid_api_key ) ) { stifli_flex_mcp_log('wp_generate_video: ERROR - no API key configured for provider=' . $vid_provider); $r['error'] = array( 'code' => -32603, 'message' => 'No video AI API key configured. Go to StifLi Flex MCP > Multimedia > Videos to set one.' ); break; } stifli_flex_mcp_log('wp_generate_video: API key resolved (length=' . strlen( $vid_api_key ) . ')'); $vid_binary = false; $vid_mime = 'video/mp4'; $vid_gen_err = ''; $vid_model_used = ''; if ( 'gemini' === $vid_provider ) { // ── Google Veo video generation ── $vid_gem_model = ! empty( $vid_mm['video_gemini_model'] ) ? $vid_mm['video_gemini_model'] : 'veo-3.0-generate-preview'; $vid_model_used = $vid_gem_model; stifli_flex_mcp_log('wp_generate_video: Using Veo model=' . $vid_gem_model . ' src_image=' . ( $vid_src_image ? 'yes' : 'no' ) . ' end_image=' . ( $vid_end_image ? 'yes' : 'no' )); // Step 1: Submit generation request $veo_api_url = 'https://generativelanguage.googleapis.com/v1beta/models/' . $vid_gem_model . ':predictLongRunning?key=' . $vid_api_key; stifli_flex_mcp_log('wp_generate_video: Submitting Veo generation request...'); // Build the instance object — add reference images when provided $veo_instance = array( 'prompt' => $vid_prompt ); if ( $vid_src_image ) { $veo_instance['image'] = array( 'bytesBase64Encoded' => $vid_src_image['data'], 'mimeType' => $vid_src_image['mime'], ); } // End-frame for interpolation (Veo only) if ( $vid_end_image ) { $veo_instance['endImage'] = array( 'bytesBase64Encoded' => $vid_end_image['data'], 'mimeType' => $vid_end_image['mime'], ); } $veo_params = array( 'aspectRatio' => $vid_aspect, 'durationSeconds' => intval( $vid_duration ), ); // personGeneration is not supported when using image input if ( empty( $vid_src_image ) && empty( $vid_end_image ) ) { $veo_params['personGeneration'] = 'allow_all'; } $veo_body = array( 'instances' => array( $veo_instance ), 'parameters' => $veo_params, ); $veo_resp = wp_remote_post( $veo_api_url, array( 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => wp_json_encode( $veo_body ), 'timeout' => 60, ) ); if ( is_wp_error( $veo_resp ) ) { $vid_gen_err = 'Veo API error: ' . $veo_resp->get_error_message(); stifli_flex_mcp_log('wp_generate_video: Veo WP error: ' . $vid_gen_err); } else { $veo_http = wp_remote_retrieve_response_code( $veo_resp ); $veo_json = json_decode( wp_remote_retrieve_body( $veo_resp ), true ); stifli_flex_mcp_log('wp_generate_video: Veo submit response HTTP ' . $veo_http); if ( 200 !== $veo_http ) { $vid_gen_err = 'Veo API error (HTTP ' . $veo_http . '): ' . ( isset( $veo_json['error']['message'] ) ? $veo_json['error']['message'] : wp_remote_retrieve_body( $veo_resp ) ); stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } else { // Extract operation name for polling $veo_op_name = isset( $veo_json['name'] ) ? $veo_json['name'] : ''; if ( empty( $veo_op_name ) ) { $vid_gen_err = 'Veo did not return an operation name. Response: ' . wp_json_encode( $veo_json ); stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } else { stifli_flex_mcp_log('wp_generate_video: Veo operation started: ' . $veo_op_name); } } } // Step 2: Poll for completion if ( empty( $vid_gen_err ) && ! empty( $veo_op_name ) ) { $veo_poll_url = 'https://generativelanguage.googleapis.com/v1beta/' . $veo_op_name . '?key=' . $vid_api_key; $veo_elapsed = 0; $veo_done = false; $veo_result = null; stifli_flex_mcp_log('wp_generate_video: Starting Veo poll loop (interval=' . $vid_poll . 's, max=' . $vid_max_wait . 's)'); while ( $veo_elapsed < $vid_max_wait ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.sleep_sleep -- async poll wait for video generation. sleep( $vid_poll ); $veo_elapsed += $vid_poll; $poll_resp = wp_remote_get( $veo_poll_url, array( 'timeout' => 30 ) ); if ( is_wp_error( $poll_resp ) ) { continue; // retry } $poll_http = wp_remote_retrieve_response_code( $poll_resp ); $poll_json = json_decode( wp_remote_retrieve_body( $poll_resp ), true ); if ( 200 !== $poll_http ) { continue; // retry } $veo_is_done = isset( $poll_json['done'] ) && true === $poll_json['done']; if ( $veo_is_done ) { // Check for errors if ( isset( $poll_json['error'] ) ) { $vid_gen_err = 'Veo generation failed: ' . ( isset( $poll_json['error']['message'] ) ? $poll_json['error']['message'] : wp_json_encode( $poll_json['error'] ) ); stifli_flex_mcp_log('wp_generate_video: Veo operation error: ' . $vid_gen_err); } else { $veo_result = $poll_json; stifli_flex_mcp_log('wp_generate_video: Veo operation completed after ' . $veo_elapsed . 's'); } $veo_done = true; break; } else { stifli_flex_mcp_log('wp_generate_video: Veo poll ' . $veo_elapsed . 's/' . $vid_max_wait . 's - still processing...'); } } if ( ! $veo_done && empty( $vid_gen_err ) ) { $vid_gen_err = 'Veo video generation timed out after ' . $vid_max_wait . ' seconds. The video may still be processing. Operation: ' . $veo_op_name; stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } } // Step 3: Extract video data if ( empty( $vid_gen_err ) && $veo_result ) { $veo_videos = array(); // Response structure varies: response.generateVideoResponse.generatedSamples[] OR response.generatedSamples[] if ( isset( $veo_result['response']['generateVideoResponse']['generatedSamples'] ) ) { $veo_videos = $veo_result['response']['generateVideoResponse']['generatedSamples']; } elseif ( isset( $veo_result['response']['generatedSamples'] ) ) { $veo_videos = $veo_result['response']['generatedSamples']; } elseif ( isset( $veo_result['response']['predictions'] ) ) { $veo_videos = $veo_result['response']['predictions']; } // Check RAI filters at either nesting level $veo_rai = array(); if ( isset( $veo_result['response']['generateVideoResponse']['raiMediaFilteredReasons'] ) ) { $veo_rai = $veo_result['response']['generateVideoResponse']['raiMediaFilteredReasons']; } elseif ( isset( $veo_result['response']['raiMediaFilteredReasons'] ) ) { $veo_rai = $veo_result['response']['raiMediaFilteredReasons']; } if ( empty( $veo_videos ) ) { if ( ! empty( $veo_rai[0] ) ) { $vid_gen_err = 'Veo blocked video generation due to safety filters: ' . $veo_rai[0]; } else { $vid_gen_err = 'Veo returned no video data. Response: ' . wp_json_encode( $veo_result ); } stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } else { stifli_flex_mcp_log('wp_generate_video: Veo returned ' . count( $veo_videos ) . ' video sample(s)'); $first_video = $veo_videos[0]; // Try encodedVideo (base64) first, then URI if ( isset( $first_video['video']['encodedVideo'] ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding AI-generated video binary. $vid_binary = base64_decode( $first_video['video']['encodedVideo'] ); if ( false === $vid_binary ) { $vid_gen_err = 'Failed to decode Veo base64 video data.'; $vid_binary = false; } } elseif ( isset( $first_video['video']['uri'] ) ) { // Download from URI — append API key for authenticated access $veo_dl_url = $first_video['video']['uri']; $veo_dl_url .= ( strpos( $veo_dl_url, '?' ) !== false ? '&' : '?' ) . 'key=' . $vid_api_key; stifli_flex_mcp_log('wp_generate_video: Downloading Veo video from URI...'); $vid_dl = wp_remote_get( $veo_dl_url, array( 'timeout' => 120 ) ); if ( is_wp_error( $vid_dl ) ) { $vid_gen_err = 'Failed to download Veo video: ' . $vid_dl->get_error_message(); } elseif ( 200 !== wp_remote_retrieve_response_code( $vid_dl ) ) { $vid_gen_err = 'Failed to download Veo video (HTTP ' . wp_remote_retrieve_response_code( $vid_dl ) . ')'; } else { $vid_binary = wp_remote_retrieve_body( $vid_dl ); } } elseif ( isset( $first_video['encodedVideo'] ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding AI-generated video binary. $vid_binary = base64_decode( $first_video['encodedVideo'] ); if ( false === $vid_binary ) { $vid_gen_err = 'Failed to decode Veo base64 video data.'; $vid_binary = false; } } elseif ( isset( $first_video['bytesBase64Encoded'] ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding AI-generated video binary. $vid_binary = base64_decode( $first_video['bytesBase64Encoded'] ); if ( false === $vid_binary ) { $vid_gen_err = 'Failed to decode Veo base64 video data.'; $vid_binary = false; } } else { $vid_gen_err = 'Veo video sample has no recognized data field. Keys: ' . implode( ', ', array_keys( $first_video ) ); } } } } else { // ── OpenAI Sora video generation ── $vid_oai_model = ! empty( $vid_mm['video_openai_model'] ) ? $vid_mm['video_openai_model'] : 'sora-2'; $vid_model_used = $vid_oai_model; stifli_flex_mcp_log('wp_generate_video: Using Sora model=' . $vid_oai_model . ' src_image=' . ( $vid_src_image ? 'yes' : 'no' )); // Map aspect ratio to Sora size format (only sizes supported by the API) $sora_size_map = array( '16:9' => '1280x720', '9:16' => '720x1280', ); $sora_size = isset( $sora_size_map[ $vid_aspect ] ) ? $sora_size_map[ $vid_aspect ] : '1280x720'; // Map duration to Sora allowed seconds: "4", "8", "12" $vid_dur_int = intval( $vid_duration ); $sora_seconds_allowed = array( 4, 8, 12 ); $sora_seconds = '8'; // default $best_diff = PHP_INT_MAX; foreach ( $sora_seconds_allowed as $s_val ) { $diff = abs( $vid_dur_int - $s_val ); if ( $diff < $best_diff ) { $best_diff = $diff; $sora_seconds = (string) $s_val; } } // Step 1: Submit generation request via multipart/form-data $boundary = 'sflmcp' . wp_generate_password( 16, false ); $multipart_body = ''; // Add text fields $sora_fields = array( 'model' => $vid_oai_model, 'prompt' => $vid_prompt, 'seconds' => $sora_seconds, 'size' => $sora_size, ); foreach ( $sora_fields as $fname => $fval ) { $multipart_body .= '--' . $boundary . "\r\n"; $multipart_body .= 'Content-Disposition: form-data; name="' . $fname . '"' . "\r\n\r\n"; $multipart_body .= $fval . "\r\n"; } // Image reference (multipart file upload) if ( $vid_src_image ) { $img_ext_map = array( 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp' ); $img_ext = isset( $img_ext_map[ $vid_src_image['mime'] ] ) ? $img_ext_map[ $vid_src_image['mime'] ] : 'jpg'; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- decoding previously encoded image for multipart upload. $img_binary = base64_decode( $vid_src_image['data'] ); // Sora requires input_reference to match requested width x height exactly. $sora_dims = explode( 'x', $sora_size ); $target_w = intval( $sora_dims[0] ); $target_h = intval( $sora_dims[1] ); $gd_src = @imagecreatefromstring( $img_binary ); if ( $gd_src ) { $orig_w = imagesx( $gd_src ); $orig_h = imagesy( $gd_src ); if ( $orig_w !== $target_w || $orig_h !== $target_h ) { stifli_flex_mcp_log( 'wp_generate_video: Resizing reference image from ' . $orig_w . 'x' . $orig_h . ' to ' . $target_w . 'x' . $target_h ); $gd_dst = imagecreatetruecolor( $target_w, $target_h ); // Preserve transparency for PNG. imagealphablending( $gd_dst, false ); imagesavealpha( $gd_dst, true ); imagecopyresampled( $gd_dst, $gd_src, 0, 0, 0, 0, $target_w, $target_h, $orig_w, $orig_h ); imagedestroy( $gd_src ); // Output as JPEG for smaller size and broader compatibility. ob_start(); imagejpeg( $gd_dst, null, 90 ); $img_binary = ob_get_clean(); imagedestroy( $gd_dst ); $img_ext = 'jpg'; $vid_src_image['mime'] = 'image/jpeg'; stifli_flex_mcp_log( 'wp_generate_video: Resized image size=' . strlen( $img_binary ) . ' bytes' ); } else { imagedestroy( $gd_src ); } } $multipart_body .= '--' . $boundary . "\r\n"; $multipart_body .= 'Content-Disposition: form-data; name="input_reference"; filename="reference.' . $img_ext . '"' . "\r\n"; $multipart_body .= 'Content-Type: ' . $vid_src_image['mime'] . "\r\n\r\n"; $multipart_body .= $img_binary . "\r\n"; } $multipart_body .= '--' . $boundary . '--' . "\r\n"; stifli_flex_mcp_log('wp_generate_video: Submitting Sora generation request (model=' . $vid_oai_model . ', size=' . $sora_size . ', seconds=' . $sora_seconds . ', payload=' . strlen( $multipart_body ) . ' bytes)...'); // Increase timeout for large payloads (e.g. image file uploads) $sora_submit_timeout = strlen( $multipart_body ) > 500000 ? 120 : 60; $sora_resp = wp_remote_post( 'https://api.openai.com/v1/videos', array( 'headers' => array( 'Authorization' => 'Bearer ' . $vid_api_key, 'Content-Type' => 'multipart/form-data; boundary=' . $boundary, ), 'body' => $multipart_body, 'timeout' => $sora_submit_timeout, ) ); $sora_gen_id = ''; if ( is_wp_error( $sora_resp ) ) { $vid_gen_err = 'Sora API error: ' . $sora_resp->get_error_message(); stifli_flex_mcp_log('wp_generate_video: Sora WP error: ' . $vid_gen_err); } else { $sora_http = wp_remote_retrieve_response_code( $sora_resp ); $sora_json = json_decode( wp_remote_retrieve_body( $sora_resp ), true ); stifli_flex_mcp_log('wp_generate_video: Sora submit response HTTP ' . $sora_http); if ( $sora_http < 200 || $sora_http >= 300 ) { $sora_err_msg = ''; if ( isset( $sora_json['error']['message'] ) ) { $sora_err_msg = $sora_json['error']['message']; } elseif ( is_array( $sora_json ) ) { $sora_err_msg = wp_json_encode( $sora_json ); } else { $sora_err_msg = wp_remote_retrieve_body( $sora_resp ); } $vid_gen_err = 'Sora API error (HTTP ' . $sora_http . '): ' . $sora_err_msg; stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } else { $sora_gen_id = isset( $sora_json['id'] ) ? $sora_json['id'] : ''; if ( empty( $sora_gen_id ) ) { $vid_gen_err = 'Sora did not return a video ID. Response: ' . wp_json_encode( $sora_json ); stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } else { $sora_status = isset( $sora_json['status'] ) ? $sora_json['status'] : 'unknown'; stifli_flex_mcp_log('wp_generate_video: Sora generation started: ID=' . $sora_gen_id . ' status=' . $sora_status); } } } // Step 2: Poll for completion via GET /v1/videos/{video_id} if ( empty( $vid_gen_err ) && ! empty( $sora_gen_id ) ) { $sora_poll_url = 'https://api.openai.com/v1/videos/' . $sora_gen_id; $sora_elapsed = 0; $sora_done = false; stifli_flex_mcp_log('wp_generate_video: Starting Sora poll loop (interval=' . $vid_poll . 's, max=' . $vid_max_wait . 's)'); while ( $sora_elapsed < $vid_max_wait ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.sleep_sleep -- async poll wait for video generation. sleep( $vid_poll ); $sora_elapsed += $vid_poll; $spoll = wp_remote_get( $sora_poll_url, array( 'headers' => array( 'Authorization' => 'Bearer ' . $vid_api_key ), 'timeout' => 15, ) ); if ( is_wp_error( $spoll ) ) { stifli_flex_mcp_log('wp_generate_video: Sora poll error at ' . $sora_elapsed . 's: ' . $spoll->get_error_message()); continue; } $spoll_json = json_decode( wp_remote_retrieve_body( $spoll ), true ); $spoll_status = isset( $spoll_json['status'] ) ? $spoll_json['status'] : ''; $spoll_progress = isset( $spoll_json['progress'] ) ? $spoll_json['progress'] : 0; if ( 'completed' === $spoll_status ) { $sora_done = true; stifli_flex_mcp_log('wp_generate_video: Sora completed after ' . $sora_elapsed . 's'); break; } elseif ( 'failed' === $spoll_status ) { $fail_reason = ''; if ( isset( $spoll_json['error']['message'] ) ) { $fail_reason = $spoll_json['error']['message']; } elseif ( isset( $spoll_json['failure_reason'] ) ) { $fail_reason = $spoll_json['failure_reason']; } else { $fail_reason = 'Unknown failure'; } $vid_gen_err = 'Sora video generation failed: ' . $fail_reason; stifli_flex_mcp_log('wp_generate_video: Sora failed: ' . $vid_gen_err); $sora_done = true; break; } // status is 'in_progress' or 'queued' stifli_flex_mcp_log('wp_generate_video: Sora poll ' . $sora_elapsed . 's/' . $vid_max_wait . 's - status=' . $spoll_status . ' progress=' . $spoll_progress . '%'); } if ( ! $sora_done && empty( $vid_gen_err ) ) { $vid_gen_err = 'Sora video generation timed out after ' . $vid_max_wait . ' seconds. Video ID: ' . $sora_gen_id; stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } } // Step 3: Download the video via GET /v1/videos/{video_id}/content if ( empty( $vid_gen_err ) && ! empty( $sora_gen_id ) && $sora_done ) { $sora_dl_url = 'https://api.openai.com/v1/videos/' . $sora_gen_id . '/content'; stifli_flex_mcp_log('wp_generate_video: Downloading Sora video from content endpoint...'); $vid_dl = wp_remote_get( $sora_dl_url, array( 'headers' => array( 'Authorization' => 'Bearer ' . $vid_api_key ), 'timeout' => 120, ) ); if ( is_wp_error( $vid_dl ) ) { $vid_gen_err = 'Failed to download Sora video: ' . $vid_dl->get_error_message(); stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } else { $sora_dl_http = wp_remote_retrieve_response_code( $vid_dl ); if ( 200 !== $sora_dl_http && 302 !== $sora_dl_http ) { $vid_gen_err = 'Failed to download Sora video (HTTP ' . $sora_dl_http . ')'; stifli_flex_mcp_log('wp_generate_video: ' . $vid_gen_err); } else { $vid_binary = wp_remote_retrieve_body( $vid_dl ); $vid_ct = wp_remote_retrieve_header( $vid_dl, 'content-type' ); if ( stripos( $vid_ct, 'webm' ) !== false ) { $vid_mime = 'video/webm'; } stifli_flex_mcp_log('wp_generate_video: Sora video downloaded, size=' . strlen( $vid_binary ) . ' bytes, content-type=' . $vid_ct); } } } } // Handle generation error if ( false === $vid_binary || empty( $vid_binary ) ) { stifli_flex_mcp_log('wp_generate_video: FAILED - ' . ( $vid_gen_err ? $vid_gen_err : 'unknown error' )); $r['error'] = array( 'code' => -32603, 'message' => $vid_gen_err ? $vid_gen_err : 'Video generation failed.' ); break; } stifli_flex_mcp_log('wp_generate_video: Video binary received, size=' . strlen( $vid_binary ) . ' bytes, mime=' . $vid_mime); // Save as WordPress media attachment. Avoid media_handle_sideload() // here because metadata extraction can stall long-running workers. if ( ! function_exists( 'wp_upload_bits' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $vid_ext_map = array( 'video/mp4' => 'mp4', 'video/webm' => 'webm' ); $vid_ext = isset( $vid_ext_map[ $vid_mime ] ) ? $vid_ext_map[ $vid_mime ] : 'mp4'; $vid_filename = 'ai-video-' . gmdate( 'Ymd-His' ) . '-' . wp_generate_password( 6, false ) . '.' . $vid_ext; stifli_flex_mcp_log('wp_generate_video: Writing video to uploads as ' . $vid_filename); $vid_upload = wp_upload_bits( $vid_filename, null, $vid_binary ); unset( $vid_binary ); if ( ! empty( $vid_upload['error'] ) ) { stifli_flex_mcp_log('wp_generate_video: Upload bits error: ' . $vid_upload['error']); $r['error'] = array( 'code' => 'write_error', 'message' => 'Failed to write video file: ' . $vid_upload['error'] ); break; } if ( empty( $vid_upload['file'] ) || ! file_exists( $vid_upload['file'] ) ) { stifli_flex_mcp_log('wp_generate_video: Upload bits returned no readable file'); $r['error'] = array( 'code' => 'write_error', 'message' => 'Failed to create video file in uploads.' ); break; } stifli_flex_mcp_log('wp_generate_video: Upload file ready path=' . $vid_upload['file']); $vid_filetype = wp_check_filetype( $vid_upload['file'], null ); $vid_attachment = array( 'guid' => $vid_upload['url'], 'post_mime_type' => ! empty( $vid_filetype['type'] ) ? $vid_filetype['type'] : $vid_mime, 'post_title' => $vid_title ? $vid_title : preg_replace( '/\.[^.]+$/', '', basename( $vid_filename ) ), 'post_content' => '', 'post_status' => 'inherit', ); $vid_att_id = wp_insert_attachment( $vid_attachment, $vid_upload['file'], $vid_post_id, true ); if ( is_wp_error( $vid_att_id ) ) { stifli_flex_mcp_log('wp_generate_video: Attachment insert error: ' . $vid_att_id->get_error_message()); wp_delete_file( $vid_upload['file'] ); $r['error'] = array( 'code' => 'upload_error', 'message' => $vid_att_id->get_error_message() ); break; } $vid_att_id = (int) $vid_att_id; update_attached_file( $vid_att_id, $vid_upload['file'] ); stifli_flex_mcp_log('wp_generate_video: Saved as attachment ID=' . $vid_att_id . ' file=' . $vid_upload['file']); $vid_metadata_scheduled = false; if ( ! wp_next_scheduled( 'sflmcp_process_generated_video_attachment', array( $vid_att_id ) ) ) { $vid_schedule_result = wp_schedule_single_event( time() + 1, 'sflmcp_process_generated_video_attachment', array( $vid_att_id ) ); if ( is_wp_error( $vid_schedule_result ) ) { stifli_flex_mcp_log('wp_generate_video: Failed to schedule metadata processing for ID=' . $vid_att_id . ' message=' . $vid_schedule_result->get_error_message()); } else { $vid_metadata_scheduled = ( false !== $vid_schedule_result ); } if ( $vid_metadata_scheduled && function_exists( 'spawn_cron' ) ) { spawn_cron( time() ); } stifli_flex_mcp_log('wp_generate_video: Scheduled attachment metadata processing for ID=' . $vid_att_id . ' scheduled=' . ( $vid_metadata_scheduled ? 'yes' : 'no' )); } else { $vid_metadata_scheduled = true; stifli_flex_mcp_log('wp_generate_video: Attachment metadata already scheduled for ID=' . $vid_att_id); } // Set title if ( $vid_title ) { wp_update_post( array( 'ID' => $vid_att_id, 'post_title' => $vid_title ) ); } $vid_att_url = wp_get_attachment_url( $vid_att_id ); $vid_result_data = array( 'attachment_id' => $vid_att_id, 'url' => $vid_att_url, 'provider' => $vid_provider, 'model' => $vid_model_used, 'duration' => $vid_duration, 'aspect_ratio' => $vid_aspect, 'mime_type' => $vid_mime, 'metadata_scheduled' => $vid_metadata_scheduled, 'prompt' => $vid_prompt, 'has_source_image' => ! empty( $vid_src_image ), 'has_end_image' => ! empty( $vid_end_image ), ); stifli_flex_mcp_log('wp_generate_video: === SUCCESS === attachment_id=' . $vid_att_id . ' url=' . $vid_att_url . ' provider=' . $vid_provider . ' model=' . $vid_model_used . ' duration=' . $vid_duration . 's'); $addResultText( $r, wp_json_encode( $vid_result_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); stifli_flex_mcp_log('wp_generate_video: === END ==='); break; case 'search': $s = sanitize_text_field($utils::getArrayValue($args, 'q', $utils::getArrayValue($args, 'query', ''))); $limit = max(1, intval($utils::getArrayValue($args, 'limit', 10, 1))); $paged = max(1, intval($utils::getArrayValue($args, 'paged', 1, 1))); $hasOffset = array_key_exists('offset', $args); $offset = $hasOffset ? max(0, intval($args['offset'])) : null; // Keep paged behavior when offset is 0, otherwise page 2 can be stuck on page 1. $useOffset = null !== $offset && $offset > 0; $includePagination = $isTruthy($utils::getArrayValue($args, 'include_pagination', false)); $searchOrderby = sanitize_key($utils::getArrayValue($args, 'orderby', 'date')); $searchOrder = strtoupper(sanitize_text_field($utils::getArrayValue($args, 'order', 'DESC'))); $queryArgs = array( 's' => $s, 'post_type' => sanitize_key($utils::getArrayValue($args, 'post_type', 'post')), 'post_status' => sanitize_key($utils::getArrayValue($args, 'post_status', 'publish')), 'posts_per_page' => $limit, 'orderby' => '' !== $searchOrderby ? $searchOrderby : 'date', 'order' => in_array($searchOrder, array('ASC', 'DESC'), true) ? $searchOrder : 'DESC', 'no_found_rows' => !$includePagination, ); $searchAuthor = intval($utils::getArrayValue($args, 'author', 0, 1)); if ($searchAuthor > 0) { $queryArgs['author'] = $searchAuthor; } $searchCategory = sanitize_title($utils::getArrayValue($args, 'category', '')); if ('' !== $searchCategory) { $queryArgs['category_name'] = $searchCategory; } $searchTag = sanitize_title($utils::getArrayValue($args, 'tag', '')); if ('' !== $searchTag) { $queryArgs['tag'] = $searchTag; } if ($useOffset) { $queryArgs['offset'] = $offset; } else { $queryArgs['paged'] = $paged; } $searchDate = array(); if (!empty($args['after'])) { $searchDate['after'] = sanitize_text_field($args['after']); } if (!empty($args['before'])) { $searchDate['before'] = sanitize_text_field($args['before']); } if (!empty($searchDate)) { $queryArgs['date_query'] = array($searchDate); } $q = new WP_Query($queryArgs); $out = array(); foreach ($q->posts as $p) { $out[] = array( 'ID' => $p->ID, 'post_type' => $p->post_type, 'post_status' => $p->post_status, 'post_title' => $p->post_title, 'excerpt' => $postExcerpt($p), 'permalink' => get_permalink($p), ); } if ($includePagination) { $effectiveOffset = $useOffset ? $offset : (($paged - 1) * $limit); $out = array( 'items' => $out, 'pagination' => $buildPaginationMeta((int) $q->found_posts, $limit, $effectiveOffset, $paged), ); } $addResultText($r, wp_json_encode($out, JSON_PRETTY_PRINT)); break; case 'fetch': $rawUrl = (string) $utils::getArrayValue($args, 'url'); $queryParams = $sanitizeQueryParams($utils::getArrayValue($args, 'query_params', array())); if (!empty($queryParams)) { $rawUrl = add_query_arg($queryParams, $rawUrl); } $url = esc_url_raw($rawUrl); if (!$url) { $r['error'] = array('code' => -42602, 'message' => 'url required'); break; } // SSRF protection: block requests to private/reserved IP ranges. $fetch_host = wp_parse_url( $url, PHP_URL_HOST ); if ( ! $fetch_host ) { $r['error'] = array('code' => -42602, 'message' => 'Invalid URL: cannot resolve host.'); break; } $fetch_ip = gethostbyname( $fetch_host ); if ( $fetch_ip === $fetch_host && ! filter_var( $fetch_host, FILTER_VALIDATE_IP ) ) { $r['error'] = array('code' => -42602, 'message' => 'Invalid URL: DNS resolution failed.'); break; } if ( $fetch_ip && ! filter_var( $fetch_ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { $r['error'] = array('code' => -42603, 'message' => 'Blocked: target resolves to a private or reserved IP range.'); break; } $timeoutSec = max(1, min(20, intval($utils::getArrayValue($args, 'timeout_sec', 10, 1)))); $maxRedirects = max(0, min(10, intval($utils::getArrayValue($args, 'max_redirects', 3, 1)))); $headOnly = $isTruthy($utils::getArrayValue($args, 'head_only', false)); $includeHeaders = $isTruthy($utils::getArrayValue($args, 'include_headers', false)); $includeRequestHeaders = $isTruthy($utils::getArrayValue($args, 'include_request_headers', false)); $extractText = $isTruthy($utils::getArrayValue($args, 'extract_text', false)); $maxBytes = max(128, min(50000, intval($utils::getArrayValue($args, 'max_bytes', 2000, 1)))); $method = strtoupper($utils::getArrayValue($args, 'method', 'GET')); if ($headOnly) { $method = 'HEAD'; } $requestHeaders = $sanitizeHeaderMap($utils::getArrayValue($args, 'headers', array())); $acceptHeader = trim((string) $utils::getArrayValue($args, 'accept', '')); if ('' !== $acceptHeader && !$hasHeaderName($requestHeaders, 'Accept')) { $requestHeaders['Accept'] = sanitize_text_field($acceptHeader); } $contentTypeHeader = trim((string) $utils::getArrayValue($args, 'content_type', '')); if ('HEAD' !== $method && '' !== $contentTypeHeader && !$hasHeaderName($requestHeaders, 'Content-Type')) { $requestHeaders['Content-Type'] = sanitize_text_field($contentTypeHeader); } $userAgent = trim((string) $utils::getArrayValue($args, 'user_agent', '')); $opts = array( 'timeout' => $timeoutSec, 'redirection' => $maxRedirects, ); if ('' !== $userAgent) { $opts['user-agent'] = sanitize_text_field($userAgent); } if (!empty($requestHeaders)) { $opts['headers'] = $requestHeaders; } if (!empty($args['body'])) { $opts['body'] = $args['body']; } if ('HEAD' === $method) { $resp = wp_remote_head($url, $opts); } elseif ('GET' === $method) { $resp = wp_remote_get($url, $opts); } else { $resp = wp_remote_request($url, array_merge($opts, array('method' => $method))); } if (is_wp_error($resp)) { $r['error'] = array('code' => 'fetch_error', 'message' => $resp->get_error_message()); break; } $code = wp_remote_retrieve_response_code($resp); $body = 'HEAD' === $method ? '' : wp_remote_retrieve_body($resp); if ($extractText && '' !== $body) { $body = trim((string) preg_replace('/\s+/', ' ', wp_strip_all_tags($body))); } $bodyShort = (strlen($body) > $maxBytes) ? substr($body, 0, $maxBytes) . '... [truncated]' : $body; if ($includeHeaders || 'HEAD' === $method) { $headers = wp_remote_retrieve_headers($resp); if (is_object($headers) && method_exists($headers, 'getAll')) { $headers = $headers->getAll(); } elseif (!is_array($headers)) { $headers = (array) $headers; } $payload = array( 'status' => (int) $code, 'method' => $method, 'url' => $url, 'response_message' => wp_remote_retrieve_response_message($resp), 'content_type' => wp_remote_retrieve_header($resp, 'content-type'), ); if ($includeRequestHeaders) { $payload['request_headers'] = $requestHeaders; } if ($includeHeaders) { $payload['headers'] = $headers; } if ('HEAD' !== $method) { $payload['body'] = $bodyShort; } $addResultText($r, wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); } else { $addResultText($r, "Fetch status: $code\n" . $bodyShort); } break; case 'wp_rm_get_head': if ( ! function_exists( 'rank_math' ) ) { $r['error'] = array('code' => -32603, 'message' => 'Rank Math SEO is not active on this site.'); break; } $rm_url = esc_url_raw( $utils::getArrayValue( $args, 'url', '' ) ); if ( empty( $rm_url ) ) { $r['error'] = array('code' => -32602, 'message' => 'Missing required parameter: url'); break; } if ( class_exists( 'RankMath\\Helper' ) && method_exists( 'RankMath\\Helper', 'get_settings' ) ) { $rm_headless = call_user_func( array( 'RankMath\\Helper', 'get_settings' ), 'general.headless_support' ); if ( empty( $rm_headless ) ) { $r['error'] = array('code' => -32603, 'message' => 'Rank Math Headless CMS Support is disabled. Enable it in Rank Math > General Settings > Others.'); break; } } $rm_head_req = new WP_REST_Request( 'GET', '/rankmath/v1/getHead' ); $rm_head_req->set_param( 'url', $rm_url ); $rm_head_res = rest_do_request( $rm_head_req ); if ( $rm_head_res->is_error() ) { $rm_error = $rm_head_res->as_error(); $r['error'] = array('code' => -32603, 'message' => $rm_error->get_error_message()); break; } $addResultText( $r, wp_json_encode( $rm_head_res->get_data(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; case 'wp_rm_get_post_seo': if ( ! function_exists( 'rank_math' ) ) { $r['error'] = array('code' => -32603, 'message' => 'Rank Math SEO is not active on this site.'); break; } $rm_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); if ( ! $rm_post_id ) { $r['error'] = array('code' => -32602, 'message' => 'Missing required parameter: post_id'); break; } if ( ! get_post( $rm_post_id ) ) { $r['error'] = array('code' => -32602, 'message' => 'Post not found.'); break; } $rm_meta_keys = array( 'rank_math_title', 'rank_math_description', 'rank_math_focus_keyword', 'rank_math_robots', 'rank_math_canonical_url', 'rank_math_facebook_title', 'rank_math_facebook_description', 'rank_math_facebook_image', 'rank_math_twitter_title', 'rank_math_twitter_description', 'rank_math_twitter_image', 'rank_math_pillar_content', ); $rm_data = array( 'post_id' => $rm_post_id ); foreach ( $rm_meta_keys as $rm_meta_key ) { $rm_data[ $rm_meta_key ] = get_post_meta( $rm_post_id, $rm_meta_key, true ); } $addResultText( $r, wp_json_encode( $rm_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; case 'wp_rm_update_post_seo': if ( ! function_exists( 'rank_math' ) ) { $r['error'] = array('code' => -32603, 'message' => 'Rank Math SEO is not active on this site.'); break; } $rm_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); if ( ! $rm_post_id ) { $r['error'] = array('code' => -32602, 'message' => 'Missing required parameter: post_id'); break; } $rm_post = get_post( $rm_post_id ); if ( ! $rm_post ) { $r['error'] = array('code' => -32602, 'message' => 'Post not found.'); break; } if ( ! current_user_can( 'edit_post', $rm_post_id ) ) { $r['error'] = array('code' => -32603, 'message' => 'You do not have permission to edit this post.'); break; } $rm_map = array( 'title' => 'rank_math_title', 'description' => 'rank_math_description', 'focus_keyword' => 'rank_math_focus_keyword', 'canonical_url' => 'rank_math_canonical_url', 'facebook_title' => 'rank_math_facebook_title', 'facebook_description' => 'rank_math_facebook_description', 'twitter_title' => 'rank_math_twitter_title', 'twitter_description' => 'rank_math_twitter_description', 'facebook_image' => 'rank_math_facebook_image', 'twitter_image' => 'rank_math_twitter_image', ); $rm_url_fields = array( 'canonical_url', 'facebook_image', 'twitter_image' ); $rm_updated = array(); foreach ( $rm_map as $rm_arg_key => $rm_meta_key ) { if ( isset( $args[ $rm_arg_key ] ) ) { $rm_value = in_array( $rm_arg_key, $rm_url_fields, true ) ? esc_url_raw( $args[ $rm_arg_key ] ) : sanitize_text_field( $args[ $rm_arg_key ] ); if ( false !== update_post_meta( $rm_post_id, $rm_meta_key, $rm_value ) ) { $rm_updated[] = $rm_arg_key; } } } $addResultText( $r, wp_json_encode( array( 'post_id' => $rm_post_id, 'updated_fields' => $rm_updated, ), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; // ── Yoast SEO ───────────────────────────────────────────────────── case 'yoast_get_meta': if ( ! defined( 'WPSEO_VERSION' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Yoast SEO plugin is not active.' ); break; } $ys_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); if ( ! $ys_post_id || ! get_post( $ys_post_id ) ) { $r['error'] = array( 'code' => -32602, 'message' => 'Invalid or missing post_id.' ); break; } $ys_keys = array( '_yoast_wpseo_title' => 'title', '_yoast_wpseo_metadesc' => 'description', '_yoast_wpseo_focuskw' => 'focus_keyword', '_yoast_wpseo_canonical' => 'canonical', '_yoast_wpseo_meta-robots-noindex' => 'noindex', '_yoast_wpseo_meta-robots-nofollow' => 'nofollow', '_yoast_wpseo_opengraph-title' => 'facebook_title', '_yoast_wpseo_opengraph-description' => 'facebook_description', '_yoast_wpseo_opengraph-image' => 'facebook_image', '_yoast_wpseo_twitter-title' => 'twitter_title', '_yoast_wpseo_twitter-description' => 'twitter_description', '_yoast_wpseo_twitter-image' => 'twitter_image', ); $ys_data = array( 'post_id' => $ys_post_id ); foreach ( $ys_keys as $ys_meta_key => $ys_field ) { $ys_data[ $ys_field ] = get_post_meta( $ys_post_id, $ys_meta_key, true ); } $addResultText( $r, wp_json_encode( $ys_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; case 'yoast_set_meta': if ( ! defined( 'WPSEO_VERSION' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Yoast SEO plugin is not active.' ); break; } $ys_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); if ( ! $ys_post_id || ! get_post( $ys_post_id ) ) { $r['error'] = array( 'code' => -32602, 'message' => 'Invalid or missing post_id.' ); break; } $ys_map = array( 'title' => '_yoast_wpseo_title', 'description' => '_yoast_wpseo_metadesc', 'focus_keyword' => '_yoast_wpseo_focuskw', 'canonical' => '_yoast_wpseo_canonical', 'facebook_title' => '_yoast_wpseo_opengraph-title', 'facebook_description' => '_yoast_wpseo_opengraph-description', 'facebook_image' => '_yoast_wpseo_opengraph-image', 'twitter_title' => '_yoast_wpseo_twitter-title', 'twitter_description' => '_yoast_wpseo_twitter-description', 'twitter_image' => '_yoast_wpseo_twitter-image', ); $ys_bool_map = array( 'noindex' => '_yoast_wpseo_meta-robots-noindex', 'nofollow' => '_yoast_wpseo_meta-robots-nofollow', ); $ys_updated = array(); foreach ( $ys_map as $ys_arg => $ys_meta ) { if ( isset( $args[ $ys_arg ] ) ) { $ys_val = in_array( $ys_arg, array( 'canonical', 'facebook_image', 'twitter_image' ), true ) ? esc_url_raw( $args[ $ys_arg ] ) : sanitize_text_field( $args[ $ys_arg ] ); update_post_meta( $ys_post_id, $ys_meta, $ys_val ); $ys_updated[] = $ys_arg; } } foreach ( $ys_bool_map as $ys_arg => $ys_meta ) { if ( isset( $args[ $ys_arg ] ) ) { update_post_meta( $ys_post_id, $ys_meta, $args[ $ys_arg ] ? '1' : '0' ); $ys_updated[] = $ys_arg; } } $addResultText( $r, wp_json_encode( array( 'post_id' => $ys_post_id, 'updated_fields' => $ys_updated ), JSON_PRETTY_PRINT ) ); break; case 'yoast_reindex': if ( ! defined( 'WPSEO_VERSION' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Yoast SEO plugin is not active.' ); break; } $ys_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); $ys_scope = 'site-wide'; if ( $ys_post_id ) { // Delete indexable for the post so Yoast rebuilds it on next request. if ( function_exists( 'YoastSEO' ) && class_exists( 'Yoast\\WP\\SEO\\Repositories\\Indexable_Repository' ) ) { $ys_repo = \YoastSEO()->classes->get( 'Yoast\\WP\\SEO\\Repositories\\Indexable_Repository' ); if ( $ys_repo ) { $ys_indexable = $ys_repo->find_by_id_and_type( $ys_post_id, 'post', false ); if ( $ys_indexable ) { $ys_indexable->delete(); } } } delete_post_meta( $ys_post_id, '_yoast_wpseo_content_score' ); $ys_scope = 'post:' . $ys_post_id; } else { // Trigger full reindex by bumping the version option used by Yoast's background indexing. delete_option( 'wpseo_indexation_complete' ); delete_transient( 'wpseo_total_unindexed' ); } $addResultText( $r, 'Yoast SEO reindex triggered. Scope: ' . $ys_scope ); break; // ── ACF ─────────────────────────────────────────────────────────── case 'acf_get_field_groups': if ( ! function_exists( 'acf_get_field_groups' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Advanced Custom Fields plugin is not active.' ); break; } $acf_groups = acf_get_field_groups(); $acf_out = array(); foreach ( $acf_groups as $acf_g ) { $acf_out[] = array( 'key' => $acf_g['key'], 'title' => $acf_g['title'], 'active' => $acf_g['active'], 'location' => $acf_g['location'], 'menu_order' => $acf_g['menu_order'], ); } $addResultText( $r, wp_json_encode( $acf_out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; case 'acf_get_fields': if ( ! function_exists( 'get_fields' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Advanced Custom Fields plugin is not active.' ); break; } $acf_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); if ( ! $acf_post_id ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: post_id' ); break; } $acf_fields = get_fields( $acf_post_id ); if ( false === $acf_fields ) { $acf_fields = array(); } $addResultText( $r, wp_json_encode( $acf_fields, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; case 'acf_update_field': if ( ! function_exists( 'update_field' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Advanced Custom Fields plugin is not active.' ); break; } $acf_post_id = intval( $utils::getArrayValue( $args, 'post_id', 0 ) ); $acf_field_name = sanitize_text_field( $utils::getArrayValue( $args, 'field_name', '' ) ); if ( ! $acf_post_id || '' === $acf_field_name ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameters: post_id, field_name.' ); break; } // Value can be any type (string, int, bool, array) — passed as-is. $acf_value = $utils::getArrayValue( $args, 'value', null ); $acf_result = update_field( $acf_field_name, $acf_value, $acf_post_id ); if ( false === $acf_result ) { $r['error'] = array( 'code' => -32603, 'message' => 'ACF update_field returned false. Check field name and post ID.' ); break; } $addResultText( $r, wp_json_encode( array( 'post_id' => $acf_post_id, 'field_name' => $acf_field_name, 'updated' => true ), JSON_PRETTY_PRINT ) ); break; // ── WPForms ─────────────────────────────────────────────────────── case 'wpforms_list_forms': if ( ! function_exists( 'wpforms' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'WPForms plugin is not active.' ); break; } $wpf_limit = max( 1, intval( $utils::getArrayValue( $args, 'limit', 50 ) ) ); $wpf_offset = max( 0, intval( $utils::getArrayValue( $args, 'offset', 0 ) ) ); $wpf_forms = wpforms()->form->get( '', array( 'posts_per_page' => $wpf_limit, 'offset' => $wpf_offset, 'fields' => 'ids', ) ); $wpf_out = array(); if ( $wpf_forms ) { foreach ( $wpf_forms as $wpf_form_id ) { $wpf_form = wpforms()->form->get( $wpf_form_id ); if ( $wpf_form ) { $wpf_out[] = array( 'id' => $wpf_form->ID, 'title' => $wpf_form->post_title, 'status' => $wpf_form->post_status, 'created' => $wpf_form->post_date, ); } } } $addResultText( $r, wp_json_encode( $wpf_out, JSON_PRETTY_PRINT ) ); break; case 'wpforms_get_entries': if ( ! function_exists( 'wpforms' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'WPForms plugin is not active.' ); break; } $wpf_form_id = intval( $utils::getArrayValue( $args, 'form_id', 0 ) ); if ( ! $wpf_form_id ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: form_id.' ); break; } $wpf_limit = max( 1, intval( $utils::getArrayValue( $args, 'limit', 20 ) ) ); $wpf_offset = max( 0, intval( $utils::getArrayValue( $args, 'offset', 0 ) ) ); $wpf_status = sanitize_text_field( $utils::getArrayValue( $args, 'status', '' ) ); $wpf_query = array( 'form_id' => $wpf_form_id, 'number' => $wpf_limit, 'offset' => $wpf_offset, ); if ( $wpf_status ) { $wpf_query['status'] = $wpf_status; } $wpf_entries = wpforms()->entry->get_entries( $wpf_query ); $wpf_out = array(); if ( $wpf_entries ) { foreach ( $wpf_entries as $wpf_entry ) { $wpf_out[] = array( 'entry_id' => $wpf_entry->entry_id, 'form_id' => $wpf_entry->form_id, 'date' => $wpf_entry->date, 'status' => $wpf_entry->status, 'fields' => json_decode( $wpf_entry->fields, true ), ); } } $addResultText( $r, wp_json_encode( $wpf_out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; // ── Gravity Forms ───────────────────────────────────────────────── case 'gf_list_forms': if ( ! class_exists( 'GFAPI' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Gravity Forms plugin is not active.' ); break; } $gf_active_only = isset( $args['active'] ) ? (bool) $args['active'] : null; $gf_forms = GFAPI::get_forms( $gf_active_only ); $gf_out = array(); foreach ( $gf_forms as $gf_form ) { $gf_out[] = array( 'id' => $gf_form['id'], 'title' => $gf_form['title'], 'description' => isset( $gf_form['description'] ) ? $gf_form['description'] : '', 'is_active' => ! empty( $gf_form['is_active'] ), 'entry_count' => GFAPI::count_entries( $gf_form['id'] ), ); } $addResultText( $r, wp_json_encode( $gf_out, JSON_PRETTY_PRINT ) ); break; case 'gf_get_entries': if ( ! class_exists( 'GFAPI' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Gravity Forms plugin is not active.' ); break; } $gf_form_id = intval( $utils::getArrayValue( $args, 'form_id', 0 ) ); if ( ! $gf_form_id ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: form_id.' ); break; } $gf_page_size = max( 1, intval( $utils::getArrayValue( $args, 'page_size', 20 ) ) ); $gf_offset = max( 0, intval( $utils::getArrayValue( $args, 'offset', 0 ) ) ); $gf_status = sanitize_text_field( $utils::getArrayValue( $args, 'status', 'active' ) ); $gf_search = array(); $gf_sv = sanitize_text_field( $utils::getArrayValue( $args, 'search_value', '' ) ); $gf_fid = sanitize_text_field( $utils::getArrayValue( $args, 'field_id', '' ) ); if ( $gf_sv && $gf_fid ) { $gf_search = array( 'field_filters' => array( array( 'key' => $gf_fid, 'value' => $gf_sv ) ) ); } elseif ( $gf_sv ) { $gf_search = array( 'field_filters' => array( array( 'key' => 0, 'value' => $gf_sv ) ) ); } $gf_criteria = array( 'status' => $gf_status ); $gf_sorting = array( 'key' => 'date_created', 'direction' => 'DESC', 'is_numeric' => false ); $gf_paging = array( 'offset' => $gf_offset, 'page_size' => $gf_page_size ); $gf_entries = GFAPI::get_entries( $gf_form_id, array_merge( $gf_criteria, $gf_search ), $gf_sorting, $gf_paging ); if ( is_wp_error( $gf_entries ) ) { $r['error'] = array( 'code' => -32603, 'message' => $gf_entries->get_error_message() ); break; } $addResultText( $r, wp_json_encode( $gf_entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; case 'gf_update_entry': if ( ! class_exists( 'GFAPI' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Gravity Forms plugin is not active.' ); break; } $gf_entry_id = intval( $utils::getArrayValue( $args, 'entry_id', 0 ) ); if ( ! $gf_entry_id ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: entry_id.' ); break; } $gf_entry = GFAPI::get_entry( $gf_entry_id ); if ( is_wp_error( $gf_entry ) ) { $r['error'] = array( 'code' => -32603, 'message' => $gf_entry->get_error_message() ); break; } if ( isset( $args['status'] ) ) { $gf_entry['status'] = sanitize_text_field( $args['status'] ); } if ( isset( $args['is_read'] ) ) { $gf_entry['is_read'] = $args['is_read'] ? '1' : '0'; } if ( isset( $args['is_starred'] ) ) { $gf_entry['is_starred'] = $args['is_starred'] ? '1' : '0'; } if ( isset( $args['field_values'] ) && is_array( $args['field_values'] ) ) { foreach ( $args['field_values'] as $gf_fid => $gf_fval ) { $gf_entry[ $gf_fid ] = is_string( $gf_fval ) ? sanitize_text_field( $gf_fval ) : $gf_fval; } } $gf_result = GFAPI::update_entry( $gf_entry ); if ( is_wp_error( $gf_result ) ) { $r['error'] = array( 'code' => -32603, 'message' => $gf_result->get_error_message() ); break; } $addResultText( $r, wp_json_encode( array( 'entry_id' => $gf_entry_id, 'updated' => true ), JSON_PRETTY_PRINT ) ); break; // ── Forminator ──────────────────────────────────────────────────── case 'forminator_list_forms': if ( ! class_exists( 'Forminator_API' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Forminator plugin is not active.' ); break; } $fi_type = sanitize_text_field( $utils::getArrayValue( $args, 'type', 'custom-forms' ) ); $fi_limit = max( 1, intval( $utils::getArrayValue( $args, 'limit', 50 ) ) ); switch ( $fi_type ) { case 'poll': $fi_forms = Forminator_API::get_polls( null, $fi_limit ); break; case 'quiz': $fi_forms = Forminator_API::get_quizzes( null, $fi_limit ); break; default: $fi_forms = Forminator_API::get_forms( null, $fi_limit ); } if ( is_wp_error( $fi_forms ) ) { $r['error'] = array( 'code' => -32603, 'message' => $fi_forms->get_error_message() ); break; } $fi_out = array(); if ( is_array( $fi_forms ) ) { foreach ( $fi_forms as $fi_form ) { $fi_out[] = array( 'id' => $fi_form->id, 'name' => isset( $fi_form->settings['formName'] ) ? $fi_form->settings['formName'] : '', 'status' => isset( $fi_form->settings['status'] ) ? $fi_form->settings['status'] : '', ); } } $addResultText( $r, wp_json_encode( $fi_out, JSON_PRETTY_PRINT ) ); break; case 'forminator_get_entries': if ( ! class_exists( 'Forminator_API' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'Forminator plugin is not active.' ); break; } $fi_form_id = intval( $utils::getArrayValue( $args, 'form_id', 0 ) ); if ( ! $fi_form_id ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: form_id.' ); break; } $fi_per_page = max( 1, intval( $utils::getArrayValue( $args, 'per_page', 20 ) ) ); $fi_page = max( 1, intval( $utils::getArrayValue( $args, 'page', 1 ) ) ); $fi_entries = Forminator_API::get_entries( $fi_form_id, $fi_page, $fi_per_page ); if ( is_wp_error( $fi_entries ) ) { $r['error'] = array( 'code' => -32603, 'message' => $fi_entries->get_error_message() ); break; } $fi_out = array(); if ( is_array( $fi_entries ) ) { foreach ( $fi_entries as $fi_entry ) { $fi_out[] = array( 'entry_id' => $fi_entry->entry_id, 'form_id' => $fi_entry->form_id, 'date_created' => $fi_entry->date_created, 'fields' => $fi_entry->meta_data, ); } } $addResultText( $r, wp_json_encode( $fi_out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; case 'wp_get_post_meta': if (!current_user_can('manage_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'No tienes permisos para manipular meta.'); break; } $post_id = isset($args['post_id']) ? intval($args['post_id']) : 0; $meta_key = isset($args['meta_key']) ? sanitize_text_field($args['meta_key']) : ''; if (!$post_id || !$meta_key) { $r['error'] = array('code' => 'invalid_params', 'message' => 'Faltan parámetros.'); break; } $single = isset($args['single']) ? (bool)$args['single'] : true; $value = get_post_meta($post_id, $meta_key, $single); // Redact secrets before exposing meta values to the LLM. if ( StifliFlexMcpUtils::keyLooksSensitive( $meta_key ) ) { $value = is_scalar( $value ) && '' !== (string) $value ? '[REDACTED]' : $value; } else { $value = StifliFlexMcpUtils::redactSecrets( $value, $meta_key ); } $metaValueLog = wp_json_encode($value, JSON_PRETTY_PRINT); if (false === $metaValueLog) { $metaValueLog = '[unserializable]'; } $addResultText($r, 'Valor de meta (' . $meta_key . ') para post ' . $post_id . ': ' . $metaValueLog); break; case 'wp_update_post_meta': if (!current_user_can('manage_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'No tienes permisos para manipular meta.'); break; } $post_id = isset($args['post_id']) ? intval($args['post_id']) : 0; $meta_key = isset($args['meta_key']) ? sanitize_text_field($args['meta_key']) : ''; $meta_value = isset($args['meta_value']) ? maybe_serialize($args['meta_value']) : null; if (!$post_id || !$meta_key) { $r['error'] = array('code' => 'invalid_params', 'message' => 'Faltan parámetros.'); break; } $updated = update_post_meta($post_id, $meta_key, $meta_value); if ($updated) { $addResultText($r, 'Meta creado/actualizado para post ' . $post_id . ' (' . $meta_key . ')'); } else { $addResultText($r, 'No se pudo crear/actualizar el metadato para post ' . $post_id . ' (' . $meta_key . ')'); } break; case 'wp_delete_post_meta': if (!current_user_can('manage_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'No tienes permisos para manipular meta.'); break; } $post_id = isset($args['post_id']) ? intval($args['post_id']) : 0; $meta_key = isset($args['meta_key']) ? sanitize_text_field($args['meta_key']) : ''; $meta_value = isset($args['meta_value']) ? $args['meta_value'] : null; if (!$post_id || !$meta_key) { $r['error'] = array('code' => 'invalid_params', 'message' => 'Faltan parámetros.'); break; } $deleted = delete_post_meta($post_id, $meta_key, $meta_value); if ($deleted) { $addResultText($r, 'Metadato (' . $meta_key . ') eliminado para post ' . $post_id); } else { $addResultText($r, 'No se eliminó el metadato (' . $meta_key . ') para post ' . $post_id); } break; case 'wp_get_option': if (!current_user_can('manage_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'No tienes permisos para manipular opciones.'); break; } $option = isset($args['option']) ? sanitize_text_field($args['option']) : ''; if (!$option) { $r['error'] = array('code' => 'invalid_params', 'message' => 'Falta el parámetro option.'); break; } $val = get_option($option); // Redact secrets recursively. If the option key itself looks // sensitive (e.g. *_api_key), the whole value is masked. if ( StifliFlexMcpUtils::keyLooksSensitive( $option ) ) { $val = is_scalar( $val ) && '' !== (string) $val ? '[REDACTED]' : StifliFlexMcpUtils::redactSecrets( $val, $option ); } else { $val = StifliFlexMcpUtils::redactSecrets( $val, $option ); } $optionValueLog = wp_json_encode($val, JSON_PRETTY_PRINT); if (false === $optionValueLog) { $optionValueLog = '[unserializable]'; } $addResultText($r, 'Valor de opción (' . $option . '): ' . $optionValueLog); break; case 'wp_get_plugin_settings': if (!current_user_can('manage_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'No tienes permisos para leer opciones de plugins.'); break; } global $wpdb; $plugin_slug = sanitize_key((string) $utils::getArrayValue($args, 'plugin_slug', '')); if ('' === $plugin_slug) { $r['error'] = array('code' => 'invalid_params', 'message' => 'plugin_slug is required.'); break; } $limit = intval($utils::getArrayValue($args, 'limit', 100)); if ($limit <= 0) { $limit = 100; } if ($limit > 300) { $limit = 300; } $summary_raw = $utils::getArrayValue($args, 'summary', false); $summary = in_array($summary_raw, array(true, 1, '1', 'true', 'yes', 'on'), true); $known_mappings = array( 'rank-math' => array('rank_math', 'rank-math', 'rankmath'), 'yoast' => array('wpseo', 'yoast'), 'woocommerce' => array('woocommerce', 'wc_'), 'acf' => array('acf'), 'elementor' => array('elementor', '_elementor'), 'wpforms' => array('wpforms'), 'stifli-flex-mcp' => array('sflmcp', 'stifli_flex_mcp'), ); $base_variants = array( $plugin_slug, str_replace('-', '_', $plugin_slug), str_replace('_', '-', $plugin_slug), ); $prefix_map = array(); foreach ($base_variants as $variant) { $clean_variant = sanitize_key((string) $variant); if ('' !== $clean_variant) { $prefix_map[$clean_variant] = true; } } if (isset($known_mappings[$plugin_slug]) && is_array($known_mappings[$plugin_slug])) { foreach ($known_mappings[$plugin_slug] as $mapped_prefix) { $clean_prefix = sanitize_key((string) $mapped_prefix); if ('' !== $clean_prefix) { $prefix_map[$clean_prefix] = true; } } } $custom_prefixes = $utils::getArrayValue($args, 'option_prefixes', array()); if (is_array($custom_prefixes)) { foreach ($custom_prefixes as $custom_prefix) { $clean_prefix = sanitize_key((string) $custom_prefix); if ('' !== $clean_prefix) { $prefix_map[$clean_prefix] = true; } } } $exact_matches = array_keys($prefix_map); $like_prefixes = array(); foreach ($base_variants as $variant) { $clean_variant = sanitize_key((string) $variant); if ('' !== $clean_variant) { // Required explicit pattern: plugin_slug + '_%'. $like_prefixes[$clean_variant . '_'] = true; } } foreach ($exact_matches as $exact_prefix) { $like_prefixes[$exact_prefix] = true; } $where = array(); $params = array(); foreach ($exact_matches as $exact_match) { $where[] = 'option_name = %s'; $params[] = $exact_match; } foreach (array_keys($like_prefixes) as $like_prefix) { $where[] = 'option_name LIKE %s'; $params[] = $wpdb->esc_like($like_prefix) . '%'; } if (empty($where)) { $payload = array( 'count' => 0, 'options' => array(), ); $r['result'] = array( 'content' => array( array('type' => 'text', 'text' => wp_json_encode($payload, JSON_PRETTY_PRINT)), ), ); break; } $params[] = $limit; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Dynamic WHERE SQL is composed from static placeholder fragments; values are bound via $wpdb->prepare. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT option_name, option_value FROM {$wpdb->options} WHERE (" . implode(' OR ', $where) . ') ORDER BY option_name ASC LIMIT %d', $params ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $count_redactions = function($value) use (&$count_redactions) { if (is_string($value) && '[REDACTED]' === $value) { return 1; } if (is_array($value)) { $sum = 0; foreach ($value as $child) { $sum += $count_redactions($child); } return $sum; } if (is_object($value)) { return $count_redactions((array) $value); } return 0; }; $result_options = array(); $example_keys = array(); $sensitive_fields_detected = 0; if (!is_array($rows)) { $rows = array(); } foreach ($rows as $row) { $option_name = isset($row['option_name']) ? (string) $row['option_name'] : ''; $raw_value = isset($row['option_value']) ? maybe_unserialize($row['option_value']) : null; // First apply strict key-based redaction helper, then run the // existing recursive/value-pattern redaction as a second pass. $redacted_value = sflmcp_redact_sensitive_value($raw_value, $option_name); $redacted_value = StifliFlexMcpUtils::redactSecrets($redacted_value, $option_name); $sensitive_fields_detected += $count_redactions($redacted_value); if (count($example_keys) < 20) { $example_keys[] = $option_name; } if (!$summary) { $result_options[] = array( 'option_name' => $option_name, 'option_value' => $redacted_value, ); } } if ($summary) { $payload = array( 'total_options' => count($rows), 'example_keys' => $example_keys, 'sensitive_fields_detected' => (int) $sensitive_fields_detected, ); } else { $payload = array( 'count' => count($result_options), 'options' => $result_options, ); } stifli_flex_mcp_log('wp_get_plugin_settings executed', array( 'plugin_slug' => $plugin_slug, 'rows' => count($rows), 'summary' => $summary, 'limit' => $limit, )); $payload_json = wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if (false === $payload_json) { $payload_json = '{}'; } $r['result'] = array( 'content' => array( array('type' => 'text', 'text' => $payload_json), ), ); break; case 'wp_update_option': if (!current_user_can('manage_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'No tienes permisos para manipular opciones.'); break; } $option = isset($args['option']) ? sanitize_text_field($args['option']) : ''; $value = isset($args['value']) ? $args['value'] : null; if (!$option) { $r['error'] = array('code' => 'invalid_params', 'message' => 'Falta el parámetro option.'); break; } // Hard denylist + sensitive-pattern check + optional allowlist. $writable = StifliFlexMcpUtils::checkOptionWritable( $option ); if ( true !== $writable ) { $messages = array( 'option_denied_hard' => 'This option is on the hard denylist and cannot be modified via MCP.', 'option_denied_sensitive_pattern' => 'This option name matches a sensitive pattern (key/secret/token/etc.) and cannot be modified via MCP.', 'option_not_in_allowlist' => 'Strict mode is enabled and this option is not in the writable allowlist (filter sflmcp_writable_options).', 'invalid_option' => 'Invalid option name.', ); $msg = isset( $messages[ $writable ] ) ? $messages[ $writable ] : 'Option write blocked.'; $r['error'] = array( 'code' => 'option_write_blocked', 'message' => $msg . ' (' . $option . ')' ); break; } $old_val = get_option($option, null); $updated = update_option($option, $value); if ($updated) { $addResultText($r, 'Opción (' . $option . ') actualizada correctamente.'); } else if ($old_val === $value) { $addResultText($r, 'La opción (' . $option . ') ya tenía ese valor, no se modificó.'); } else { $addResultText($r, 'No se pudo actualizar la opción (' . $option . ').'); } break; // wp_delete_option intentionally removed: too destructive without reliable undo. case 'wp_get_settings': if (!current_user_can('manage_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'No tienes permisos para leer configuración.'); break; } $keys = isset($args['keys']) && is_array($args['keys']) ? $args['keys'] : array(); if (empty($keys)) { // Return common settings if no keys specified $keys = array('blogname', 'blogdescription', 'siteurl', 'home', 'admin_email', 'users_can_register', 'default_role', 'timezone_string', 'date_format', 'time_format', 'posts_per_page', 'comments_per_page'); } $settings = array(); foreach ($keys as $key) { $clean_key = sanitize_text_field($key); $raw = get_option($clean_key); if ( StifliFlexMcpUtils::keyLooksSensitive( $clean_key ) ) { $settings[$clean_key] = is_scalar($raw) && '' !== (string) $raw ? '[REDACTED]' : StifliFlexMcpUtils::redactSecrets( $raw, $clean_key ); } else { $settings[$clean_key] = StifliFlexMcpUtils::redactSecrets( $raw, $clean_key ); } } $addResultText($r, wp_json_encode($settings, JSON_PRETTY_PRINT)); break; case 'wp_update_settings': if (!current_user_can('manage_options')) { $r['error'] = array('code' => 'permission_denied', 'message' => 'No tienes permisos para actualizar configuración.'); break; } $settings = isset($args['settings']) && is_array($args['settings']) ? $args['settings'] : array(); if (empty($settings)) { $r['error'] = array('code' => 'invalid_params', 'message' => 'Falta el parámetro settings (debe ser un objeto con pares clave-valor).'); break; } $updated = array(); $blocked = array(); foreach ($settings as $key => $value) { $key = sanitize_text_field($key); $writable = StifliFlexMcpUtils::checkOptionWritable( $key ); if ( true !== $writable ) { $blocked[$key] = $writable; continue; } $result = update_option($key, $value); $updated[$key] = $result; } $msg = 'Configuración actualizada: ' . wp_json_encode($updated, JSON_PRETTY_PRINT); if ( ! empty( $blocked ) ) { $msg .= "\nOpciones bloqueadas (no se modificaron): " . wp_json_encode( $blocked ); } $addResultText($r, $msg); break; // Post Revisions case 'wp_get_post_revisions': $post_id = intval($utils::getArrayValue($args, 'post_id', 0)); if (empty($post_id)) { $r['error'] = array('code' => -42602, 'message' => 'post_id required'); break; } $revisions = wp_get_post_revisions($post_id); $result = array(); foreach ($revisions as $revision) { $result[] = array( 'id' => $revision->ID, 'post_author' => $revision->post_author, 'post_date' => $revision->post_date, 'post_title' => $revision->post_title, 'post_modified' => $revision->post_modified, ); } $addResultText($r, 'Found ' . count($result) . ' revisions: ' . wp_json_encode($result, JSON_PRETTY_PRINT)); break; case 'wp_restore_post_revision': $revision_id = intval($utils::getArrayValue($args, 'revision_id', 0)); if (empty($revision_id)) { $r['error'] = array('code' => -42602, 'message' => 'revision_id required'); break; } $restored = wp_restore_post_revision($revision_id); if ($restored) { $addResultText($r, 'Post restored to revision #' . $revision_id . ', restored post ID: ' . $restored); } else { $r['error'] = array('code' => -42603, 'message' => 'Failed to restore revision'); } break; // Custom Post Types case 'wp_get_post_types': $public_only = (bool) $utils::getArrayValue($args, 'public_only', false); $args_query = array(); if ($public_only) { $args_query['public'] = true; } $post_types = get_post_types($args_query, 'objects'); $result = array(); foreach ($post_types as $post_type) { $result[] = array( 'name' => $post_type->name, 'label' => $post_type->label, 'labels' => (array) $post_type->labels, 'public' => $post_type->public, 'hierarchical' => $post_type->hierarchical, 'has_archive' => $post_type->has_archive, 'supports' => get_all_post_type_supports($post_type->name), 'taxonomies' => get_object_taxonomies($post_type->name), 'rest_enabled' => $post_type->show_in_rest, ); } $addResultText($r, 'Found ' . count($result) . ' post types: ' . wp_json_encode($result, JSON_PRETTY_PRINT)); break; // Site Health case 'wp_get_site_health': $audit_level = $this->normalizeSiteHealthAuditLevel( $utils::getArrayValue( $args, 'level', 0 ) ); $health_report = $this->getSiteHealthAuditReport( $audit_level ); $summary = isset($health_report['summary']) && is_array($health_report['summary']) ? $health_report['summary'] : array(); $headline = sprintf( 'Site audit level %d (%s). Score %d/100 (%s). Critical issues: %d. Recommended improvements: %d. Checks run: %d.', intval( $health_report['audit_level']['value'] ?? $audit_level ), isset( $health_report['audit_level']['label'] ) ? $health_report['audit_level']['label'] : 'basic', intval( $summary['score'] ?? 0 ), isset( $summary['overall_status'] ) ? $summary['overall_status'] : 'unknown', intval( $summary['critical'] ?? 0 ), intval( $summary['recommended'] ?? 0 ), intval( $summary['tests_run'] ?? 0 ) ); $addResultText( $r, $headline . "\n\n" . wp_json_encode( $health_report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); break; /* ── Changelog / Audit Log Tools ───────────────────── */ case 'mcp_get_changelog': $tracker = StifliFlexMcp_ChangeTracker::getInstance(); $cl_page = max( 1, intval( $args['page'] ?? 1 ) ); $cl_pp = max( 1, min( 100, intval( $args['per_page'] ?? 25 ) ) ); $cl_f = array( 'limit' => $cl_pp, 'offset' => ( $cl_page - 1 ) * $cl_pp ); if ( ! empty( $args['tool_name'] ) ) $cl_f['tool_name'] = sanitize_text_field( $args['tool_name'] ); if ( ! empty( $args['operation_type'] ) ) $cl_f['operation_type'] = sanitize_key( $args['operation_type'] ); if ( ! empty( $args['object_type'] ) ) $cl_f['object_type'] = sanitize_key( $args['object_type'] ); if ( ! empty( $args['date_from'] ) ) $cl_f['date_from'] = sanitize_text_field( $args['date_from'] ) . ' 00:00:00'; if ( ! empty( $args['date_to'] ) ) $cl_f['date_to'] = sanitize_text_field( $args['date_to'] ) . ' 23:59:59'; if ( isset( $args['rolled_back'] ) ) $cl_f['rolled_back'] = intval( $args['rolled_back'] ); $cl_data = $tracker->getHistory( $cl_f ); $addResultText( $r, wp_json_encode( array( 'page' => $cl_page, 'per_page' => $cl_pp, 'total' => $cl_data['total'], 'rows' => $cl_data['rows'], ), JSON_PRETTY_PRINT ) ); break; case 'mcp_get_change_detail': $cl_id = intval( $args['id'] ?? 0 ); if ( ! $cl_id ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: id' ); break; } global $wpdb; $cl_tbl = $wpdb->prefix . 'sflmcp_changelog'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- table name from $wpdb->prefix is safe. $cl_row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `{$cl_tbl}` WHERE id = %d", $cl_id ), ARRAY_A ); if ( ! $cl_row ) { $r['error'] = array( 'code' => -32602, 'message' => 'Changelog entry not found.' ); break; } $addResultText( $r, wp_json_encode( $cl_row, JSON_PRETTY_PRINT ) ); break; case 'mcp_rollback_change': $cl_id = intval( $args['id'] ?? 0 ); if ( ! $cl_id ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: id' ); break; } $cl_res = StifliFlexMcp_ChangeTracker::getInstance()->rollback( $cl_id ); if ( $cl_res['success'] ) { $addResultText( $r, 'Rollback successful: ' . $cl_res['message'] ); } else { $r['error'] = array( 'code' => -32603, 'message' => $cl_res['message'] ); } break; case 'mcp_redo_change': $cl_id = intval( $args['id'] ?? 0 ); if ( ! $cl_id ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: id' ); break; } $cl_res = StifliFlexMcp_ChangeTracker::getInstance()->redo( $cl_id ); if ( $cl_res['success'] ) { $addResultText( $r, 'Redo successful: ' . $cl_res['message'] ); } else { $r['error'] = array( 'code' => -32603, 'message' => $cl_res['message'] ); } break; case 'mcp_rollback_session': $cl_sid = sanitize_text_field( $args['session_id'] ?? '' ); if ( empty( $cl_sid ) ) { $r['error'] = array( 'code' => -32602, 'message' => 'Missing required parameter: session_id' ); break; } $cl_res = StifliFlexMcp_ChangeTracker::getInstance()->rollbackSession( $cl_sid ); if ( $cl_res['success'] ) { $addResultText( $r, 'Session rollback complete. ' . $cl_res['message'] ); } else { $r['error'] = array( 'code' => -32603, 'message' => $cl_res['message'] ); } break; default: // Try to route to WooCommerce modules if tool starts with wc_ if ( strpos( $tool, 'wc_' ) === 0 && class_exists( 'WooCommerce' ) ) { $this->maybeLoadWooCommerceModules(); // Try WC Products module if ( class_exists( 'StifliFlexMcp_WC_Products' ) ) { $result = StifliFlexMcp_WC_Products::dispatch( $tool, $args, $r, $addResultText, $utils ); if ( $result !== null ) { $recordChangeIfNeeded(); return $r; } } // Try WC Orders module if ( class_exists( 'StifliFlexMcp_WC_Orders' ) ) { $result = StifliFlexMcp_WC_Orders::dispatch( $tool, $args, $r, $addResultText, $utils ); if ( $result !== null ) { $recordChangeIfNeeded(); return $r; } } // Try WC Customers module if ( class_exists( 'StifliFlexMcp_WC_Customers' ) ) { $result = StifliFlexMcp_WC_Customers::dispatch( $tool, $args, $r, $addResultText, $utils ); if ( $result !== null ) { $recordChangeIfNeeded(); return $r; } } // Try WC Coupons module if ( class_exists( 'StifliFlexMcp_WC_Coupons' ) ) { $result = StifliFlexMcp_WC_Coupons::dispatch( $tool, $args, $r, $addResultText, $utils ); if ( $result !== null ) { $recordChangeIfNeeded(); return $r; } } // Try WC System module if ( class_exists( 'StifliFlexMcp_WC_System' ) ) { $result = StifliFlexMcp_WC_System::dispatch( $tool, $args, $r, $addResultText, $utils ); if ( $result !== null ) { $recordChangeIfNeeded(); return $r; } } } // Try Snippets module (snippet_* tools) if ( strpos( $tool, 'snippet_' ) === 0 ) { if ( $this->maybeLoadSnippetsModule() && class_exists( 'StifliFlexMcp_Snippets' ) ) { $result = StifliFlexMcp_Snippets::dispatch( $tool, $args, $r, $addResultText, $utils ); if ( $result !== null ) { $recordChangeIfNeeded(); return $r; } } else { $r['error'] = array( 'code' => -32603, 'message' => 'Snippet tools module is not installed in this plugin package.', ); return $r; } } // Try The Events Calendar module (wp_tec_* tools) if ( strpos( $tool, 'wp_tec_' ) === 0 ) { if ( ! $this->maybeLoadTecIntegrationModule() ) { $r['error'] = array( 'code' => -32603, 'message' => class_exists( 'Tribe__Events__Main' ) ? 'The Events Calendar REST API is not available.' : 'The Events Calendar plugin is not active.', ); return $r; } if ( class_exists( 'StifliFlexMcp_TheEventsCalendar' ) ) { $result = StifliFlexMcp_TheEventsCalendar::dispatch( $tool, $args, $r, $addResultText, $utils ); if ( $result !== null ) { $recordChangeIfNeeded(); return $r; } } } // Try Custom Tools (from sflmcp_custom_tools table) if ( strpos( $tool, 'custom_' ) === 0 ) { $r = $this->dispatchCustomTool( $tool, $args, $id, $r ); $recordChangeIfNeeded(); return $r; } // Try WordPress Abilities (ability_* tools from sflmcp_abilities table) if ( strpos( $tool, 'ability_' ) === 0 ) { $r = $this->dispatchAbility( $tool, $args, $id, $r ); $recordChangeIfNeeded(); return $r; } // If not handled by any WooCommerce module or unknown tool $r['error'] = array('code' => -42609, 'message' => 'Unknown tool'); } // Change Tracker: record change if operation succeeded $recordChangeIfNeeded(); return $r; } /** * Build an extended Site Health audit report. * * @param int $level Audit depth: 0 basic, 1 medium, 2 deep. * @return array */ private function getSiteHealthAuditReport( $level = 0 ) { global $wpdb; $level = $this->normalizeSiteHealthAuditLevel( $level ); if ( $level >= 2 && file_exists( ABSPATH . 'wp-admin/includes/admin.php' ) ) { require_once ABSPATH . 'wp-admin/includes/admin.php'; } if ( ! function_exists( 'get_plugins' ) && file_exists( ABSPATH . 'wp-admin/includes/plugin.php' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( ! class_exists( 'WP_Site_Health' ) && file_exists( ABSPATH . 'wp-admin/includes/class-wp-site-health.php' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-site-health.php'; } if ( ! class_exists( 'WP_Debug_Data' ) && file_exists( ABSPATH . 'wp-admin/includes/class-wp-debug-data.php' ) ) { require_once ABSPATH . 'wp-admin/includes/class-wp-debug-data.php'; } $theme = wp_get_theme(); $uploads = wp_upload_dir(); $all_plugins = function_exists( 'get_plugins' ) ? get_plugins() : array(); $active_plugins = (array) get_option( 'active_plugins', array() ); $memory_limit = defined( 'WP_MEMORY_LIMIT' ) ? WP_MEMORY_LIMIT : ''; $max_memory = defined( 'WP_MAX_MEMORY_LIMIT' ) ? WP_MAX_MEMORY_LIMIT : ''; $site_profile = array( 'wordpress' => array( 'version' => get_bloginfo( 'version' ), 'environment_type' => function_exists( 'wp_get_environment_type' ) ? wp_get_environment_type() : 'production', 'site_url' => get_site_url(), 'home_url' => get_home_url(), 'is_multisite' => is_multisite(), 'language' => get_bloginfo( 'language' ), 'https' => is_ssl(), ), 'server' => array( 'php_version' => phpversion(), 'server_software' => isset( $_SERVER['SERVER_SOFTWARE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) : 'Unknown', 'database_version' => method_exists( $wpdb, 'db_version' ) ? $wpdb->db_version() : '', 'database_charset' => defined( 'DB_CHARSET' ) ? DB_CHARSET : '', 'table_prefix' => $wpdb->prefix, ), 'theme' => array( 'name' => $theme->get( 'Name' ), 'version' => $theme->get( 'Version' ), 'parent_theme' => $theme->get( 'Template' ), ), 'plugins' => array( 'active' => count( $active_plugins ), 'inactive' => max( 0, count( $all_plugins ) - count( $active_plugins ) ), 'total' => count( $all_plugins ), ), 'runtime' => array( 'wp_memory_limit' => $memory_limit, 'wp_memory_limit_bytes' => function_exists( 'wp_convert_hr_to_bytes' ) ? wp_convert_hr_to_bytes( $memory_limit ) : null, 'wp_max_memory_limit' => $max_memory, 'wp_max_memory_bytes' => function_exists( 'wp_convert_hr_to_bytes' ) ? wp_convert_hr_to_bytes( $max_memory ) : null, 'object_cache' => function_exists( 'wp_using_ext_object_cache' ) ? wp_using_ext_object_cache() : false, 'wp_cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, 'alternate_wp_cron' => defined( 'ALTERNATE_WP_CRON' ) && ALTERNATE_WP_CRON, ), 'debug' => array( 'wp_debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, 'wp_debug_log' => defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG, 'wp_debug_display' => defined( 'WP_DEBUG_DISPLAY' ) && WP_DEBUG_DISPLAY, 'script_debug' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG, ), 'paths' => array( 'content_dir' => defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR : '', 'plugin_dir' => defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : '', 'uploads_dir' => is_array( $uploads ) && isset( $uploads['basedir'] ) ? $uploads['basedir'] : 'Unknown', ), ); if ( class_exists( 'WooCommerce' ) && function_exists( 'WC' ) && WC() ) { $site_profile['woocommerce'] = array( 'version' => WC()->version, 'database_version' => get_option( 'woocommerce_db_version' ), ); } $tests = $this->collectSiteHealthTests( $level ); $analysis = $this->buildSiteHealthAuditSummary( $tests ); $pending_updates = $this->getSiteHealthUpdateSnapshot( $all_plugins ); $directory_sizes = $level >= 2 ? $this->getSiteHealthDirectorySizes() : array( 'skipped' => true, 'reason' => 'Directory sizes are only included at level 2 (deep) to avoid slow scans.', ); $tests_mode = 'selected direct tests'; if ( 1 === $level ) { $tests_mode = 'all direct Site Health tests'; } elseif ( 2 === $level ) { $tests_mode = 'all direct and async Site Health tests'; } return array( 'audit_level' => array( 'value' => $level, 'label' => $this->getSiteHealthAuditLevelLabel( $level ), 'tests_mode' => $tests_mode, 'storage_scan' => $level >= 2, ), 'summary' => $analysis['summary'], 'site_profile' => $site_profile, 'updates' => $pending_updates, 'storage' => $directory_sizes, 'audit' => array( 'top_findings' => $analysis['top_findings'], 'prioritized_recommendations'=> $analysis['prioritized_recommendations'], ), 'health_checks' => array( 'counts' => $analysis['counts'], 'counts_by_category' => $analysis['counts_by_category'], 'tests' => $tests, ), ); } /** * Collect Site Health tests that can run directly in the current request. * * @param int $level Audit depth. * @return array */ private function collectSiteHealthTests( $level = 0 ) { if ( ! class_exists( 'WP_Site_Health' ) || ! method_exists( 'WP_Site_Health', 'get_tests' ) ) { return array(); } $level = $this->normalizeSiteHealthAuditLevel( $level ); $site_health = method_exists( 'WP_Site_Health', 'get_instance' ) ? WP_Site_Health::get_instance() : new WP_Site_Health(); $tests = WP_Site_Health::get_tests(); $basic_direct_tests = array( 'wordpress_version', 'plugin_version', 'theme_version', 'php_version', 'php_extensions', 'php_default_timezone', 'php_sessions', 'sql_server', 'utf8mb4_support', 'debug_enabled', 'file_uploads', 'plugin_theme_auto_updates', 'opcode_cache', ); if ( function_exists( 'wp_get_environment_type' ) && 'development' === wp_get_environment_type() && isset( $tests['async']['https_status'] ) ) { unset( $tests['async']['https_status'] ); } if ( 0 === $level ) { $tests['direct'] = array_filter( $tests['direct'], function( $identifier ) use ( $basic_direct_tests ) { return in_array( $identifier, $basic_direct_tests, true ); }, ARRAY_FILTER_USE_KEY ); $tests['async'] = array(); } elseif ( 1 === $level ) { $tests['async'] = array(); } $results = array(); foreach ( $tests['direct'] as $identifier => $test_definition ) { $results[] = $this->normalizeSiteHealthTestResult( $this->executeSiteHealthTest( $site_health, $test_definition, false ), $identifier, $test_definition['label'] ?? '' ); } foreach ( $tests['async'] as $identifier => $test_definition ) { $results[] = $this->normalizeSiteHealthTestResult( $this->executeSiteHealthTest( $site_health, $test_definition, true ), $identifier, $test_definition['label'] ?? '' ); } return $results; } /** * Execute a Site Health test definition. * * @param WP_Site_Health $site_health Site health instance. * @param array $test_definition Test definition. * @param bool $is_async Whether this is an async test. * @return array */ private function executeSiteHealthTest( $site_health, $test_definition, $is_async = false ) { try { if ( $is_async ) { if ( ! empty( $test_definition['async_direct_test'] ) && is_callable( $test_definition['async_direct_test'] ) ) { return call_user_func( $test_definition['async_direct_test'] ); } if ( isset( $test_definition['test'] ) && is_callable( $test_definition['test'] ) ) { return call_user_func( $test_definition['test'] ); } } else { if ( isset( $test_definition['test'] ) && is_string( $test_definition['test'] ) ) { $test_function = sprintf( 'get_test_%s', $test_definition['test'] ); if ( method_exists( $site_health, $test_function ) && is_callable( array( $site_health, $test_function ) ) ) { return call_user_func( array( $site_health, $test_function ) ); } } if ( isset( $test_definition['test'] ) && is_callable( $test_definition['test'] ) ) { return call_user_func( $test_definition['test'] ); } } } catch ( Throwable $e ) { return array( 'test' => isset( $test_definition['test'] ) && is_string( $test_definition['test'] ) ? sanitize_key( $test_definition['test'] ) : 'unknown', 'label' => isset( $test_definition['label'] ) ? $test_definition['label'] : 'Site Health test failed to execute', 'status' => 'recommended', 'badge' => array( 'label' => 'System', 'color' => 'gray', ), 'description' => 'The test could not be executed directly in the current context: ' . $e->getMessage(), 'actions' => '', ); } return array( 'test' => isset( $test_definition['test'] ) && is_string( $test_definition['test'] ) ? sanitize_key( $test_definition['test'] ) : 'unknown', 'label' => isset( $test_definition['label'] ) ? $test_definition['label'] : 'Site Health test not available', 'status' => 'recommended', 'badge' => array( 'label' => 'System', 'color' => 'gray', ), 'description' => 'The test could not be executed directly in the current context.', 'actions' => '', ); } /** * Normalize a Site Health test result for MCP output. * * @param mixed $result Test result. * @param string $identifier Test identifier. * @param string $fallback_label Fallback label. * @return array */ private function normalizeSiteHealthTestResult( $result, $identifier, $fallback_label ) { if ( is_object( $result ) ) { $result = (array) $result; } if ( ! is_array( $result ) ) { $result = array(); } $status = isset( $result['status'] ) && in_array( $result['status'], array( 'good', 'recommended', 'critical' ), true ) ? $result['status'] : 'recommended'; $badge = isset( $result['badge'] ) && is_array( $result['badge'] ) ? $result['badge'] : array(); $category = isset( $badge['label'] ) ? $this->normalizeSiteHealthText( $badge['label'] ) : 'General'; return array( 'id' => sanitize_key( $identifier ), 'test' => isset( $result['test'] ) ? sanitize_key( $result['test'] ) : sanitize_key( $identifier ), 'label' => $this->normalizeSiteHealthText( $result['label'] ?? $fallback_label ), 'status' => $status, 'category' => $category, 'badge' => array( 'label' => $category, 'color' => isset( $badge['color'] ) ? sanitize_key( $badge['color'] ) : 'gray', ), 'description' => $this->normalizeSiteHealthText( $result['description'] ?? '' ), 'actions' => $this->normalizeSiteHealthText( $result['actions'] ?? '' ), ); } /** * Build an executive summary from Site Health tests. * * @param array $tests Normalized Site Health tests. * @return array */ private function buildSiteHealthAuditSummary( $tests ) { $counts = array( 'total' => 0, 'good' => 0, 'recommended' => 0, 'critical' => 0, 'skipped' => 0, ); $counts_by_category = array(); $findings = array(); foreach ( $tests as $test ) { $status = isset( $test['status'] ) ? $test['status'] : 'recommended'; if ( ! isset( $counts[ $status ] ) ) { $counts['skipped']++; continue; } $counts['total']++; $counts[ $status ]++; $category = isset( $test['category'] ) && '' !== $test['category'] ? $test['category'] : 'General'; if ( ! isset( $counts_by_category[ $category ] ) ) { $counts_by_category[ $category ] = array( 'good' => 0, 'recommended' => 0, 'critical' => 0, ); } if ( isset( $counts_by_category[ $category ][ $status ] ) ) { $counts_by_category[ $category ][ $status ]++; } if ( 'good' !== $status ) { $findings[] = array( 'status' => $status, 'category' => $category, 'test' => $test['test'] ?? '', 'label' => $test['label'] ?? '', 'description' => $test['description'] ?? '', 'actions' => $test['actions'] ?? '', ); } } usort( $findings, function( $left, $right ) { $priority = array( 'critical' => 0, 'recommended' => 1, 'good' => 2, ); $left_priority = $priority[ $left['status'] ] ?? 9; $right_priority = $priority[ $right['status'] ] ?? 9; if ( $left_priority === $right_priority ) { return strcmp( $left['label'], $right['label'] ); } return $left_priority - $right_priority; } ); $recommendations = array(); foreach ( $findings as $finding ) { $action_text = ! empty( $finding['actions'] ) ? $finding['actions'] : $finding['description']; if ( empty( $action_text ) ) { $action_text = $finding['label']; } $recommendation_key = md5( strtolower( $finding['test'] . '|' . $action_text ) ); if ( isset( $recommendations[ $recommendation_key ] ) ) { continue; } $recommendations[ $recommendation_key ] = array( 'status' => $finding['status'], 'category' => $finding['category'], 'test' => $finding['test'], 'label' => $finding['label'], 'action' => $action_text, ); } $score = max( 0, 100 - ( $counts['critical'] * 20 ) - ( $counts['recommended'] * 7 ) ); if ( 0 === $counts['total'] ) { $score = 0; } $overall_status = 'good'; if ( $counts['critical'] > 0 ) { $overall_status = 'critical'; } elseif ( $counts['recommended'] > 0 ) { $overall_status = 'recommended'; } return array( 'summary' => array( 'overall_status' => $overall_status, 'score' => $score, 'tests_run' => $counts['total'], 'good' => $counts['good'], 'recommended' => $counts['recommended'], 'critical' => $counts['critical'], ), 'counts' => $counts, 'counts_by_category' => $counts_by_category, 'top_findings' => array_slice( $findings, 0, 8 ), 'prioritized_recommendations' => array_values( array_slice( $recommendations, 0, 8, true ) ), ); } /** * Get pending update information without forcing remote refreshes. * * @param array $all_plugins All installed plugins. * @return array */ private function getSiteHealthUpdateSnapshot( $all_plugins ) { $snapshot = array( 'core' => array( 'pending' => 0, 'items' => array(), ), 'plugins' => array( 'pending' => 0, 'items' => array(), ), 'themes' => array( 'pending' => 0, 'items' => array(), ), ); $core_updates = get_site_transient( 'update_core' ); if ( is_object( $core_updates ) && ! empty( $core_updates->updates ) && is_array( $core_updates->updates ) ) { foreach ( $core_updates->updates as $update ) { if ( ! is_object( $update ) ) { continue; } $response = isset( $update->response ) ? (string) $update->response : ''; if ( 'latest' === $response ) { continue; } $snapshot['core']['items'][] = array( 'current_version' => get_bloginfo( 'version' ), 'new_version' => isset( $update->current ) ? sanitize_text_field( $update->current ) : '', 'response' => $response, ); } } $snapshot['core']['pending'] = count( $snapshot['core']['items'] ); $plugin_updates = get_site_transient( 'update_plugins' ); if ( is_object( $plugin_updates ) && ! empty( $plugin_updates->response ) ) { foreach ( (array) $plugin_updates->response as $plugin_file => $update ) { if ( ! is_object( $update ) ) { continue; } $snapshot['plugins']['items'][] = array( 'plugin' => isset( $all_plugins[ $plugin_file ]['Name'] ) ? $all_plugins[ $plugin_file ]['Name'] : $plugin_file, 'file' => $plugin_file, 'current_version' => isset( $all_plugins[ $plugin_file ]['Version'] ) ? $all_plugins[ $plugin_file ]['Version'] : '', 'new_version' => isset( $update->new_version ) ? sanitize_text_field( $update->new_version ) : '', ); } } $snapshot['plugins']['pending'] = count( $snapshot['plugins']['items'] ); $theme_updates = get_site_transient( 'update_themes' ); $all_themes = wp_get_themes(); if ( is_object( $theme_updates ) && ! empty( $theme_updates->response ) && is_array( $theme_updates->response ) ) { foreach ( $theme_updates->response as $stylesheet => $update ) { if ( ! is_array( $update ) ) { continue; } $theme = isset( $all_themes[ $stylesheet ] ) ? $all_themes[ $stylesheet ] : null; $snapshot['themes']['items'][] = array( 'theme' => $theme ? $theme->get( 'Name' ) : $stylesheet, 'stylesheet' => $stylesheet, 'current_version' => $theme ? $theme->get( 'Version' ) : '', 'new_version' => isset( $update['new_version'] ) ? sanitize_text_field( $update['new_version'] ) : '', ); } } $snapshot['themes']['pending'] = count( $snapshot['themes']['items'] ); return $snapshot; } /** * Get directory sizes from WordPress debug data. * * @return array */ private function getSiteHealthDirectorySizes() { if ( ! class_exists( 'WP_Debug_Data' ) || ! method_exists( 'WP_Debug_Data', 'get_sizes' ) ) { return array(); } try { $sizes = WP_Debug_Data::get_sizes(); } catch ( Throwable $e ) { return array( 'error' => $e->getMessage(), ); } if ( ! is_array( $sizes ) ) { return array(); } $formatted = array(); $total_raw = 0; foreach ( $sizes as $name => $size_data ) { if ( ! is_array( $size_data ) ) { continue; } $raw_bytes = isset( $size_data['raw'] ) ? intval( $size_data['raw'] ) : 0; if ( $raw_bytes > 0 ) { $total_raw += $raw_bytes; } $formatted[ sanitize_key( $name ) ] = array( 'size' => isset( $size_data['size'] ) ? sanitize_text_field( (string) $size_data['size'] ) : ( $raw_bytes > 0 ? size_format( $raw_bytes, 2 ) : '' ), 'raw_bytes' => $raw_bytes, ); } if ( $total_raw > 0 ) { $formatted['total'] = array( 'size' => size_format( $total_raw, 2 ), 'raw_bytes' => $total_raw, ); } return $formatted; } /** * Strip HTML noise from Site Health text. * * @param mixed $text Raw Site Health text. * @return string */ private function normalizeSiteHealthText( $text ) { if ( is_array( $text ) || is_object( $text ) ) { $text = wp_json_encode( $text ); } if ( ! is_string( $text ) || '' === $text ) { return ''; } $text = html_entity_decode( wp_strip_all_tags( $text, true ), ENT_QUOTES, get_bloginfo( 'charset' ) ?: 'UTF-8' ); $text = preg_replace( '/\s+/', ' ', $text ); return trim( (string) $text ); } /** * Normalize requested audit depth. * * @param mixed $level Requested level. * @return int */ private function normalizeSiteHealthAuditLevel( $level ) { $level = intval( $level ); if ( $level < 0 ) { return 0; } if ( $level > 2 ) { return 2; } return $level; } /** * Get human-readable audit level label. * * @param int $level Audit level. * @return string */ private function getSiteHealthAuditLevelLabel( $level ) { $level = $this->normalizeSiteHealthAuditLevel( $level ); if ( 2 === $level ) { return 'deep'; } if ( 1 === $level ) { return 'medium'; } return 'basic'; } /** * Dispatch a WordPress Ability (WordPress 6.9+) * * @param string $tool The tool name (ability_*). * @param array $args The tool arguments. * @param mixed $rpcId The JSON-RPC request ID. * @param array $r The result array (passed by reference). * @return array The result array. */ private function dispatchAbility( $tool, $args, $rpcId, $r ) { $tool = is_string( $tool ) ? $tool : ( is_scalar( $tool ) ? (string) $tool : '' ); // Check if WordPress Abilities API is available if ( ! function_exists( 'wp_get_ability' ) ) { $r['error'] = array( 'code' => -32603, 'message' => 'WordPress Abilities API not available. Requires WordPress 6.9+', ); return $r; } // Get the original ability name from the tool definition $tools = $this->getTools(); if ( ! isset( $tools[ $tool ] ) || ! is_array( $tools[ $tool ] ) || ! isset( $tools[ $tool ]['_ability_name'] ) ) { $r['error'] = array( 'code' => -32602, 'message' => 'Ability not found or not properly configured', ); return $r; } $ability_name = $tools[ $tool ]['_ability_name']; // Use wp_get_ability() to get the specific ability $ability = wp_get_ability( $ability_name ); if ( ! $ability ) { $r['error'] = array( 'code' => -32602, 'message' => sprintf( 'Ability "%s" not found in registry. The plugin may have been deactivated.', $ability_name ), ); return $r; } // Check permission - abilities have their own permission_callback // The check_permission method may not exist on all ability implementations // so we'll let execute() handle permission checking internally // Execute the ability with input arguments $result = $ability->execute( $args ); if ( is_wp_error( $result ) ) { $r['error'] = array( 'code' => -32603, 'message' => $result->get_error_message(), ); return $r; } // Format successful result $result_text = is_array( $result ) ? wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) : (string) $result; $r['result'] = array( 'content' => array( array( 'type' => 'text', 'text' => $result_text, ), ), ); return $r; } }