--- 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'];
}
}