PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.4
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.4
3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / labs / mcp.php
ai-engine / labs Last commit date
mcp-core.php 5 months ago mcp-rest.php 1 year ago mcp.conf 1 year ago mcp.js 8 months ago mcp.md 8 months ago mcp.php 5 months ago
mcp.php
1470 lines
1 <?php
2
3 /**
4 * AI Engine MCP Server
5 *
6 * This class implements a Model Context Protocol (MCP) server for AI Engine.
7 *
8 * Current Implementation:
9 * - Works reliably with Claude App through the mcp.js relay
10 * - Works directly with Claude.ai and ChatGPT via SSE connections
11 * - Properly handles agent cancellation signals (notifications/cancelled) to free workers immediately
12 * - Uses 30-second timeout to prevent worker exhaustion from abandoned connections
13 * - Sends heartbeat signals to detect dead connections quickly
14 * - OAuth authentication flow is currently disabled due to security concerns
15 * (only static bearer tokens are supported)
16 *
17 * Connection Management:
18 * - Agents send notifications/cancelled when done, triggering immediate SSE closure
19 * - 30-second timeout ensures workers are freed even if agents forget to disconnect
20 * - Heartbeat comments (every 10s) help proxies and connection_aborted() detect dead sockets
21 * - Both the mcp.js relay and direct agent connections work reliably
22 */
23
24 class Meow_MWAI_Labs_MCP {
25 private $core = null;
26 private $namespace = 'mcp/v1';
27 private $server_version = '0.0.1';
28 private $protocol_version = '2025-06-18'; // Updated to match official MCP SDK
29 private $queue_key = 'mwai_mcp_msg';
30 private $session_id = null;
31 private $logging = false;
32 private $last_action_time = 0;
33 private $bearer_token = null;
34 // Placeholder for OAuth integration. Currently unused and kept for
35 // future implementation once the security model is revised.
36 private $oauth = null;
37
38 #region Initialize
39 public function __construct( $core ) {
40 $this->core = $core;
41
42 // Set logging based on option
43 $this->logging = $this->core->get_option( 'mcp_debug_mode', false );
44
45 // OAuth support is temporarily disabled due to security concerns.
46 // The previous implementation allowed unvalidated redirect URIs which
47 // introduced an open redirect vulnerability and the possibility to
48 // steal authorization codes. Until proper client registration with
49 // strict redirect URI validation is implemented, the OAuth feature is
50 // not loaded. See labs/oauth.php for the previous code and take care
51 // when re‑enabling it in the future.
52
53 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
54 }
55
56 public function is_logging_enabled() {
57 return $this->logging;
58 }
59
60 public function rest_api_init() {
61 // Load bearer token if not already loaded
62 if ( $this->bearer_token === null ) {
63 $this->bearer_token = $this->core->get_option( 'mcp_bearer_token' );
64 }
65
66 // Only add filter once
67 static $filter_added = false;
68 if ( !empty( $this->bearer_token ) && !$filter_added ) {
69 add_filter( 'mwai_allow_mcp', [ $this, 'auth_via_bearer_token' ], 10, 2 );
70 $filter_added = true;
71 }
72 register_rest_route( $this->namespace, '/sse', [
73 'methods' => [ 'GET', 'POST', 'HEAD' ], // Support HEAD for client endpoint checks
74 'callback' => [ $this, 'handle_sse' ],
75 'permission_callback' => function ( $request ) {
76 return $this->can_access_mcp( $request );
77 },
78 ] );
79
80 register_rest_route( $this->namespace, '/messages', [
81 'methods' => 'POST',
82 'callback' => [ $this, 'handle_message' ],
83 'permission_callback' => function ( $request ) {
84 return $this->can_access_mcp( $request );
85 },
86 ] );
87
88 // No-Auth URL endpoints (with token in path) - Legacy SSE
89 $noauth_enabled = $this->core->get_option( 'mcp_noauth_url' );
90 if ( $noauth_enabled && !empty( $this->bearer_token ) ) {
91 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/sse', [
92 'methods' => 'GET',
93 'callback' => [ $this, 'handle_sse' ],
94 'permission_callback' => function ( $request ) {
95 return $this->handle_noauth_access( $request );
96 },
97 'show_in_index' => false,
98 ] );
99
100 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/sse', [
101 'methods' => 'POST',
102 'callback' => [ $this, 'handle_sse' ],
103 'permission_callback' => function ( $request ) {
104 return $this->handle_noauth_access( $request );
105 },
106 'show_in_index' => false,
107 ] );
108
109 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/messages', [
110 'methods' => 'POST',
111 'callback' => [ $this, 'handle_message' ],
112 'permission_callback' => function ( $request ) {
113 return $this->handle_noauth_access( $request );
114 },
115 'show_in_index' => false,
116 ] );
117 }
118
119 // Streamable HTTP endpoint (Modern transport for Claude Code)
120 // Uses Authorization: Bearer header for authentication
121 // Automatically enabled when MCP module is active and bearer token is set
122 if ( !empty( $this->bearer_token ) ) {
123 // Main endpoint with Authorization header (at /http path)
124 register_rest_route( $this->namespace, '/http', [
125 'methods' => [ 'GET', 'POST', 'DELETE' ],
126 'callback' => [ $this, 'handle_streamable_http' ],
127 'permission_callback' => function ( $request ) {
128 return $this->can_access_mcp( $request );
129 },
130 'show_in_index' => false,
131 ] );
132
133 // Alternative endpoint with token in URL (for clients that don't support headers)
134 register_rest_route( $this->namespace, '/' . $this->bearer_token, [
135 'methods' => [ 'GET', 'POST', 'DELETE' ],
136 'callback' => [ $this, 'handle_streamable_http' ],
137 'permission_callback' => function ( $request ) {
138 return $this->handle_noauth_access_streamable( $request );
139 },
140 'show_in_index' => false,
141 ] );
142 }
143
144 // File upload endpoint for wp_upload_request
145 // Uses a one-time token in the URL for authentication (no bearer header needed from curl)
146 register_rest_route( $this->namespace, '/upload/(?P<token>[a-zA-Z0-9]+)', [
147 'methods' => 'POST',
148 'callback' => [ $this, 'handle_upload' ],
149 'permission_callback' => '__return_true',
150 'show_in_index' => false,
151 ] );
152 }
153 #endregion
154
155 #region Auth (Bearer token)
156 /**
157 * SECURITY: MCP provides powerful WordPress management capabilities, so access must be strictly controlled.
158 *
159 * By default, only administrators can access MCP endpoints. This prevents lower-privileged users
160 * (subscribers, contributors, etc.) from executing dangerous operations like creating admin users,
161 * deleting content, or modifying settings.
162 *
163 * When a bearer token is configured, it overrides the default admin check, but access is DENIED
164 * unless a valid token is provided. This ensures MCP is secure even with default settings.
165 */
166 public function can_access_mcp( $request ) {
167 // Default to requiring administrator capability for security
168 $is_admin = current_user_can( 'administrator' );
169 return apply_filters( 'mwai_allow_mcp', $is_admin, $request );
170 }
171
172 public function auth_via_bearer_token( $allow, $request ) {
173 // Skip if already authenticated as admin
174 if ( $allow ) {
175 return $allow;
176 }
177
178 $hdr = $request->get_header( 'authorization' );
179
180 // If no authorization header but bearer token is configured, deny access
181 if ( !$hdr && !empty( $this->bearer_token ) ) {
182 if ( $this->logging ) {
183 error_log( '[AI Engine MCP] ❌ No authorization header provided. Server may be stripping headers.' );
184 }
185 return false;
186 }
187
188 // Check for Bearer token in header
189 if ( $hdr && preg_match( '/Bearer\s+(.+)/i', $hdr, $m ) ) {
190 $token = trim( $m[1] );
191 $auth_result = 'none';
192
193 // Check if it's an OAuth token
194 if ( $this->oauth ) {
195 $token_data = $this->oauth->validate_token( $token );
196 if ( $token_data ) {
197 // Set current user based on OAuth token
198 wp_set_current_user( $token_data['user_id'] );
199 $auth_result = 'oauth';
200 // Only log auth for SSE endpoint
201 if ( $this->logging && strpos( $request->get_route(), '/sse' ) !== false ) {
202 error_log( '[AI Engine MCP] 🔐 OAuth OK (user: ' . $token_data['user_id'] . ')' );
203 }
204 return true;
205 }
206 }
207
208 // Fall back to static bearer token if configured
209 if ( !empty( $this->bearer_token ) && hash_equals( $this->bearer_token, $token ) ) {
210 if ( $admin = $this->core->get_admin_user() ) {
211 wp_set_current_user( $admin->ID, $admin->user_login );
212 }
213 $auth_result = 'static';
214 if ( $this->logging ) {
215 error_log( '[AI Engine MCP] 🔐 Bearer token auth OK' );
216 }
217 return true;
218 }
219
220 if ( $this->logging && $auth_result === 'none' ) {
221 error_log( '[AI Engine MCP] ❌ Bearer token invalid.' );
222 }
223 // Explicitly deny access for invalid tokens
224 return false;
225 }
226
227 // ?token=xyz fallback (optional) - only for static bearer token
228 if ( !empty( $this->bearer_token ) ) {
229 $q = sanitize_text_field( $request->get_param( 'token' ) );
230 if ( $q && hash_equals( $this->bearer_token, $q ) ) {
231 if ( $admin = $this->core->get_admin_user() ) {
232 wp_set_current_user( $admin->ID, $admin->user_login );
233 }
234 return true;
235 }
236 }
237
238 // If bearer token is configured but no valid auth provided, deny access
239 if ( !empty( $this->bearer_token ) ) {
240 return false;
241 }
242
243 return $allow;
244 }
245
246 public function handle_noauth_access( $request ) {
247 // For no-auth URLs, the token is already verified by being in the URL path
248 // Double-check that the route actually contains the token
249 $route = $request->get_route();
250 if ( strpos( $route, '/' . $this->bearer_token . '/' ) === false ) {
251 if ( $this->logging ) {
252 error_log( '[AI Engine MCP] ❌ Invalid no-auth URL access attempt.' );
253 }
254 return false;
255 }
256
257 // Set the current user to admin since token is valid
258 if ( $admin = $this->core->get_admin_user() ) {
259 wp_set_current_user( $admin->ID, $admin->user_login );
260 }
261 return true;
262 }
263
264 public function handle_noauth_access_streamable( $request ) {
265 // For Streamable HTTP with token in URL path (no trailing slash)
266 $route = $request->get_route();
267 $expected = '/' . $this->namespace . '/' . $this->bearer_token;
268 if ( $route !== $expected ) {
269 if ( $this->logging ) {
270 error_log( '[AI Engine MCP] ❌ Invalid Streamable HTTP no-auth URL access attempt.' );
271 }
272 return false;
273 }
274
275 // Set the current user to admin since token is valid
276 if ( $admin = $this->core->get_admin_user() ) {
277 wp_set_current_user( $admin->ID, $admin->user_login );
278 }
279 return true;
280 }
281
282 #endregion
283
284 #region Helpers (log / JSON-RPC utils)
285 private function log( $msg ) {
286 // This method is for internal UI logs - keep it minimal
287 if ( $this->logging ) {
288 // Only log important messages to UI
289 if ( strpos( $msg, 'queued' ) === false && strpos( $msg, 'flush' ) === false ) {
290 Meow_MWAI_Logging::log( "[AI Engine MCP] {$msg}" );
291 }
292 }
293 }
294
295 /** Wrap a JSON-RPC error object */
296 private function rpc_error( $id, int $code, string $msg, $extra = null ): array {
297 $err = [ 'code' => $code, 'message' => $msg ];
298 if ( $extra !== null ) {
299 $err['data'] = $extra;
300 }
301 return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ];
302 }
303
304 /** Queue an error for SSE delivery */
305 private function queue_error( $sess, $id, int $code, string $msg, $extra = null ): void {
306 $this->store_message( $sess, $this->rpc_error( $id, $code, $msg, $extra ) );
307 }
308
309 /** Format tool result for MCP protocol */
310 private function format_tool_result( $result ): array {
311 // If result is a string, wrap it in the MCP content format
312 if ( is_string( $result ) ) {
313 return [
314 'content' => [
315 [
316 'type' => 'text',
317 'text' => $result,
318 ],
319 ],
320 ];
321 }
322
323 // If result has 'content' key, assume it's already properly formatted
324 if ( is_array( $result ) && isset( $result['content'] ) ) {
325 return $result;
326 }
327
328 // If result is an array without 'content' key, wrap it as JSON
329 if ( is_array( $result ) ) {
330 return [
331 'content' => [
332 [
333 'type' => 'text',
334 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT ),
335 ],
336 ],
337 'data' => $result,
338 ];
339 }
340
341 // For any other type, convert to string and wrap
342 return [
343 'content' => [
344 [
345 'type' => 'text',
346 'text' => (string) $result,
347 ],
348 ],
349 ];
350 }
351 #endregion
352
353 #region Handle direct JSON-RPC (for Claude's MCP client)
354 /**
355 * Claude's MCP client (via Anthropic API) sends JSON-RPC requests directly to the SSE endpoint
356 * as POST requests, rather than following the typical SSE flow:
357 * - Normal flow: GET /sse → establish SSE stream → POST /messages for JSON-RPC
358 * - Claude's flow: POST /sse with JSON-RPC body → expect immediate JSON response
359 *
360 * This method handles the direct JSON-RPC requests to maintain compatibility with Claude.
361 */
362 private function handle_direct_jsonrpc( WP_REST_Request $request, $data ) {
363 $id = $data['id'] ?? null;
364 $method = $data['method'] ?? null;
365
366 if ( json_last_error() !== JSON_ERROR_NONE ) {
367 $response = new WP_REST_Response( [
368 'jsonrpc' => '2.0',
369 'id' => null,
370 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
371 ], 200 );
372 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
373 $session_header = $request->get_header( 'mcp-session-id' );
374 if ( !empty( $session_header ) ) {
375 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
376 }
377 return $response;
378 }
379
380 if ( !is_array( $data ) || !$method ) {
381 $response = new WP_REST_Response( [
382 'jsonrpc' => '2.0',
383 'id' => $id,
384 'error' => [ 'code' => -32600, 'message' => 'Invalid Request' ]
385 ], 200 );
386 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
387 $session_header = $request->get_header( 'mcp-session-id' );
388 if ( !empty( $session_header ) ) {
389 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
390 }
391 return $response;
392 }
393
394 $session_header = $request->get_header( 'mcp-session-id' );
395 $session_id = '';
396 if ( !empty( $session_header ) ) {
397 $session_id = sanitize_text_field( $session_header );
398 }
399
400 if ( $method === 'initialize' || empty( $session_id ) ) {
401 $session_id = wp_generate_uuid4();
402 if ( $this->logging ) {
403 error_log( '[AI Engine MCP] 🆔 Direct session initialized: ' . $session_id );
404 }
405 }
406
407 try {
408 $reply = null;
409
410 switch ( $method ) {
411 case 'initialize':
412 // Check if client requests a specific protocol version
413 $params = $data['params'] ?? [];
414 $requested_version = $params['protocolVersion'] ?? null;
415 $client_info = $params['clientInfo'] ?? null;
416
417 if ( $this->logging && $client_info ) {
418 $client_name = $client_info['name'] ?? 'unknown';
419 $client_version = $client_info['version'] ?? 'unknown';
420 error_log( "[AI Engine MCP] Client: {$client_name} v{$client_version}" );
421 }
422
423 if ( $requested_version && $requested_version !== $this->protocol_version ) {
424 if ( $this->logging ) {
425 Meow_MWAI_Logging::warn( "[AI Engine MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
426 }
427 }
428
429 $reply = [
430 'jsonrpc' => '2.0',
431 'id' => $id,
432 'result' => [
433 'protocolVersion' => $this->protocol_version,
434 'serverInfo' => (object) [
435 'name' => 'AI Engine - ' . get_bloginfo( 'name' ),
436 'version' => $this->server_version,
437 ],
438 'capabilities' => (object) [
439 'tools' => new stdClass(), // Empty object, matching official SDK
440 ],
441 ],
442 ];
443 break;
444
445 case 'tools/list':
446 $tools = $this->get_tools_list();
447
448 // Debug logging for tools/list
449 if ( $this->logging ) {
450 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
451 error_log( '[AI Engine MCP Direct] 📋 tools/list requested by: ' . $user_agent );
452 error_log( '[AI Engine MCP Direct] 📊 Returning ' . count( $tools ) . ' tools' );
453 if ( count( $tools ) > 0 ) {
454 $tool_names = array_column( $tools, 'name' );
455 error_log( '[AI Engine MCP Direct] 🛠️ Tool names: ' . implode( ', ', $tool_names ) );
456 }
457 else {
458 error_log( '[AI Engine MCP Direct] ⚠️ WARNING: No tools returned!' );
459 }
460 }
461
462 $reply = [
463 'jsonrpc' => '2.0',
464 'id' => $id,
465 'result' => [ 'tools' => $tools ],
466 ];
467 break;
468
469 case 'tools/call':
470 $params = $data['params'] ?? [];
471 $tool = $params['name'] ?? '';
472 $arguments = $params['arguments'] ?? [];
473
474 if ( $this->logging ) {
475 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Tool: ' . $tool );
476 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Arguments: ' . wp_json_encode( $arguments ) );
477 }
478
479 try {
480 $reply = $this->execute_tool( $tool, $arguments, $id );
481 if ( $this->logging ) {
482 error_log( '[AI Engine MCP Direct] �
483 tools/call - Success for tool: ' . $tool );
484 }
485 }
486 catch ( Exception $e ) {
487 if ( $this->logging ) {
488 error_log( '[AI Engine MCP Direct] tools/call - Error: ' . $e->getMessage() );
489 }
490 throw $e;
491 }
492 break;
493
494 case 'notifications/initialized':
495 // This is a notification from the client indicating it has initialized
496 // No response needed for notifications
497 // Client initialized - no need to log
498 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
499 break;
500
501 default:
502 // Check if it's a notification (no id)
503 if ( $id === null && strpos( $method, 'notifications/' ) === 0 ) {
504 if ( $this->logging ) {
505 error_log( '[AI Engine MCP] 📨 Notification received: ' . $method );
506 }
507 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
508 }
509
510 $reply = [
511 'jsonrpc' => '2.0',
512 'id' => $id,
513 'error' => [ 'code' => -32601, 'message' => "Method not found: {$method}" ]
514 ];
515 }
516
517 // Ensure proper JSON-RPC response
518 $response = new WP_REST_Response( $reply, 200 );
519 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
520 return $this->attach_session_header( $response, $session_id );
521
522 }
523 catch ( Exception $e ) {
524 if ( $this->logging ) {
525 error_log( '[AI Engine MCP] ❌ Exception in handle_direct_jsonrpc: ' . $e->getMessage() );
526 }
527
528 $error_response = new WP_REST_Response( [
529 'jsonrpc' => '2.0',
530 'id' => $id,
531 'error' => [ 'code' => -32603, 'message' => 'Internal error', 'data' => $e->getMessage() ]
532 ], 200 );
533 $error_response->set_headers( [ 'Content-Type' => 'application/json' ] );
534 return $this->attach_session_header( $error_response, $session_id );
535 }
536 }
537 #endregion
538
539 #region Handle SSE (stream loop)
540 private function reply( string $event, $data = null, string $enc = 'json' ) {
541 // Handle special events
542 if ( $event === 'bye' ) {
543 echo "event: bye\ndata: \n\n";
544 if ( ob_get_level() ) {
545 ob_end_flush();
546 }
547 flush();
548 $this->last_action_time = time();
549 $this->log( 'Clean disconnection' );
550 return;
551 }
552
553 if ( $enc === 'json' && $data === null ) {
554 $this->log( "no data for {$event}" );
555 return;
556 }
557 echo "event: {$event}\n";
558 if ( $enc === 'json' ) {
559 $data = $data === null ? '{}' : wp_json_encode( $data, JSON_UNESCAPED_UNICODE );
560 }
561 echo 'data: ' . $data . "\n\n";
562
563 if ( ob_get_level() ) {
564 ob_end_flush();
565 }
566 flush();
567
568 $this->last_action_time = time();
569 // Only log endpoint announcements
570 if ( $event === 'endpoint' ) {
571 $this->log( 'SSE endpoint ready' );
572 }
573 }
574
575 private function generate_sse_id( $req ) {
576 $last = $req ? $req->get_header( 'last-event-id' ) : '';
577 return $last ?: str_replace( '-', '', wp_generate_uuid4() );
578 }
579
580 private function attach_session_header( WP_REST_Response $response, string $session_id ) {
581 if ( empty( $session_id ) ) {
582 return $response;
583 }
584
585 $response->header( 'Mcp-Session-Id', $session_id );
586
587 if ( $this->logging ) {
588 error_log( '[AI Engine MCP] 🪪 Response session header: ' . $session_id );
589 }
590
591 return $response;
592 }
593
594 public function handle_sse( WP_REST_Request $request ) {
595 // Handle HEAD request - just confirm endpoint exists
596 if ( $request->get_method() === 'HEAD' ) {
597 return new WP_REST_Response( null, 200, [
598 'Content-Type' => 'text/event-stream',
599 'Cache-Control' => 'no-cache',
600 ] );
601 }
602
603 $raw_body = $request->get_body();
604
605 // Handle POST request with JSON-RPC body (Direct MCP client behavior)
606 // Both Claude.ai and OpenAI/ChatGPT send JSON-RPC requests directly to the SSE endpoint
607 // instead of establishing an SSE connection first. This is non-standard but we need to support it.
608 // Expected flow: GET /sse (establish stream) → POST /messages (send JSON-RPC)
609 // Actual flow: POST /sse with JSON-RPC body → expects immediate JSON response
610 if ( $request->get_method() === 'POST' && !empty( $raw_body ) ) {
611 $data = json_decode( $raw_body, true );
612 if ( $data && isset( $data['method'] ) ) {
613 // Don't log here - it's already logged by log_requests()
614 // Process as a direct JSON-RPC request instead of starting SSE stream
615 return $this->handle_direct_jsonrpc( $request, $data );
616 }
617 }
618
619 @ini_set( 'zlib.output_compression', '0' );
620 @ini_set( 'output_buffering', '0' );
621 @ini_set( 'implicit_flush', '1' );
622 if ( function_exists( 'ob_implicit_flush' ) ) {
623 ob_implicit_flush( true );
624 }
625
626 header( 'Content-Type: text/event-stream' );
627 header( 'Cache-Control: no-cache' );
628 header( 'X-Accel-Buffering: no' );
629 header( 'Connection: keep-alive' );
630 while ( ob_get_level() ) {
631 ob_end_flush();
632 }
633
634 /* — greet client —*/
635 $this->session_id = $this->generate_sse_id( $request );
636 $this->last_action_time = time();
637 echo "id: {$this->session_id}\n\n";
638 flush();
639
640 $msg_uri = sprintf(
641 '%s/messages?session_id=%s',
642 rest_url( $this->namespace ),
643 $this->session_id
644 );
645 $this->reply( 'endpoint', $msg_uri, 'text' );
646 if ( $this->logging ) {
647 error_log( '[AI Engine MCP] �
648 SSE connected (' . substr( $this->session_id, 0, 8 ) . '...)' );
649 }
650
651 /* — main loop —*/
652 while ( true ) {
653 // Reduced timeout to free workers faster when agents disconnect
654 $max_time = $this->logging ? 30 : 60 * 3; // 30 seconds in debug, 3 minutes in production
655 $idle = ( time() - $this->last_action_time ) >= $max_time;
656 if ( connection_aborted() || $idle ) {
657 $this->reply( 'bye' );
658 if ( $this->logging ) {
659 error_log( '[AI Engine MCP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
660 }
661 break;
662 }
663
664 // Send heartbeat every 10 seconds to detect dead connections
665 $time_since_last = time() - $this->last_action_time;
666 if ( $time_since_last >= 10 && $time_since_last % 10 === 0 ) {
667 echo ": heartbeat\n\n";
668 if ( ob_get_level() ) {
669 ob_end_flush();
670 }
671 flush();
672 }
673
674 foreach ( $this->fetch_messages( $this->session_id ) as $p ) {
675 // Check for kill signal in the message queue
676 if ( isset( $p['method'] ) && $p['method'] === 'mwai/kill' ) {
677 if ( $this->logging ) {
678 error_log( '[AI Engine MCP] Kill signal - terminating' );
679 }
680 $this->reply( 'bye' );
681 exit;
682 }
683
684 // Don't log SSE responses - they clutter the logs
685 $this->reply( 'message', $p );
686 }
687
688 usleep( 200000 ); // 200 ms
689 }
690 exit;
691 }
692 #endregion
693
694 #region Handle Streamable HTTP (Modern MCP transport)
695 /**
696 * Handle Streamable HTTP requests per MCP specification.
697 * This is the modern transport used by Claude Code and other MCP clients.
698 *
699 * - POST: Send JSON-RPC request, receive JSON response (or SSE for streaming)
700 * - GET: Open SSE stream for server-initiated messages
701 * - DELETE: Terminate the session
702 *
703 * @see https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
704 */
705 public function handle_streamable_http( WP_REST_Request $request ) {
706 $method = $request->get_method();
707
708 switch ( $method ) {
709 case 'POST':
710 return $this->handle_streamable_http_post( $request );
711
712 case 'GET':
713 return $this->handle_streamable_http_get( $request );
714
715 case 'DELETE':
716 return $this->handle_streamable_http_delete( $request );
717
718 default:
719 return new WP_REST_Response( [
720 'error' => 'Method not allowed'
721 ], 405 );
722 }
723 }
724
725 /**
726 * Handle POST requests for Streamable HTTP.
727 * This processes JSON-RPC requests and returns JSON responses.
728 */
729 private function handle_streamable_http_post( WP_REST_Request $request ) {
730 $raw_body = $request->get_body();
731
732 if ( empty( $raw_body ) ) {
733 return new WP_REST_Response( [
734 'jsonrpc' => '2.0',
735 'id' => null,
736 'error' => [ 'code' => -32700, 'message' => 'Parse error: empty body' ]
737 ], 400 );
738 }
739
740 $data = json_decode( $raw_body, true );
741
742 if ( json_last_error() !== JSON_ERROR_NONE ) {
743 return new WP_REST_Response( [
744 'jsonrpc' => '2.0',
745 'id' => null,
746 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
747 ], 400 );
748 }
749
750 // Log the request if debugging is enabled
751 if ( $this->logging && isset( $data['method'] ) ) {
752 error_log( '[AI Engine MCP HTTP] ↓ ' . $data['method'] );
753 }
754
755 // Reuse the existing direct JSON-RPC handler
756 return $this->handle_direct_jsonrpc( $request, $data );
757 }
758
759 /**
760 * Handle GET requests for Streamable HTTP.
761 * This opens an SSE stream for server-to-client messages.
762 * Used when the server needs to send notifications or progress updates.
763 */
764 private function handle_streamable_http_get( WP_REST_Request $request ) {
765 // Check Accept header - must accept text/event-stream
766 $accept = $request->get_header( 'accept' );
767 if ( strpos( $accept, 'text/event-stream' ) === false ) {
768 return new WP_REST_Response( [
769 'error' => 'Accept header must include text/event-stream'
770 ], 406 );
771 }
772
773 // Get or create session ID
774 $session_header = $request->get_header( 'mcp-session-id' );
775 $session_id = !empty( $session_header ) ? sanitize_text_field( $session_header ) : wp_generate_uuid4();
776
777 if ( $this->logging ) {
778 error_log( '[AI Engine MCP HTTP] 📡 SSE stream opened for session: ' . substr( $session_id, 0, 8 ) . '...' );
779 }
780
781 // Set up SSE output
782 @ini_set( 'zlib.output_compression', '0' );
783 @ini_set( 'output_buffering', '0' );
784 @ini_set( 'implicit_flush', '1' );
785 if ( function_exists( 'ob_implicit_flush' ) ) {
786 ob_implicit_flush( true );
787 }
788
789 header( 'Content-Type: text/event-stream' );
790 header( 'Cache-Control: no-cache' );
791 header( 'X-Accel-Buffering: no' );
792 header( 'Connection: keep-alive' );
793 header( 'Mcp-Session-Id: ' . $session_id );
794
795 while ( ob_get_level() ) {
796 ob_end_flush();
797 }
798
799 $this->session_id = $session_id;
800 $this->last_action_time = time();
801
802 // Send initial connection event
803 echo "event: open\n";
804 echo 'data: {"session":"' . esc_js( $session_id ) . "\"}\n\n";
805 flush();
806
807 // Main SSE loop - listen for server-initiated messages
808 while ( true ) {
809 $max_time = $this->logging ? 30 : 60 * 3;
810 $idle = ( time() - $this->last_action_time ) >= $max_time;
811
812 if ( connection_aborted() || $idle ) {
813 if ( $this->logging ) {
814 error_log( '[AI Engine MCP HTTP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
815 }
816 break;
817 }
818
819 // Check for queued messages
820 foreach ( $this->fetch_messages( $session_id ) as $msg ) {
821 if ( isset( $msg['method'] ) && $msg['method'] === 'mwai/kill' ) {
822 echo "event: close\ndata: {}\n\n";
823 flush();
824 exit;
825 }
826
827 echo "event: message\n";
828 echo 'data: ' . wp_json_encode( $msg, JSON_UNESCAPED_UNICODE ) . "\n\n";
829 flush();
830 $this->last_action_time = time();
831 }
832
833 // Heartbeat every 10 seconds
834 $time_since_last = time() - $this->last_action_time;
835 if ( $time_since_last >= 10 && $time_since_last % 10 === 0 ) {
836 echo ": heartbeat\n\n";
837 flush();
838 }
839
840 usleep( 200000 ); // 200ms
841 }
842
843 exit;
844 }
845
846 /**
847 * Handle DELETE requests for Streamable HTTP.
848 * This terminates the session and cleans up any resources.
849 */
850 private function handle_streamable_http_delete( WP_REST_Request $request ) {
851 $session_header = $request->get_header( 'mcp-session-id' );
852
853 if ( empty( $session_header ) ) {
854 return new WP_REST_Response( [
855 'error' => 'Mcp-Session-Id header required'
856 ], 400 );
857 }
858
859 $session_id = sanitize_text_field( $session_header );
860
861 if ( $this->logging ) {
862 error_log( '[AI Engine MCP HTTP] 🗑️ Session terminated: ' . substr( $session_id, 0, 8 ) . '...' );
863 }
864
865 // Queue kill signal for any active SSE streams
866 $this->store_message( $session_id, [
867 'jsonrpc' => '2.0',
868 'method' => 'mwai/kill'
869 ] );
870
871 // Clean up any remaining transients for this session
872 global $wpdb;
873 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$session_id}_" ) . '%';
874 $wpdb->query(
875 $wpdb->prepare(
876 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
877 $like
878 )
879 );
880
881 // Return 204 No Content on successful termination
882 return new WP_REST_Response( null, 204 );
883 }
884 #endregion
885
886 #region Handle /messages (JSON-RPC ingress)
887 public function handle_message( WP_REST_Request $request ) {
888 $sess = sanitize_text_field( $request->get_param( 'session_id' ) );
889 $raw = $request->get_body();
890 $dat = json_decode( $raw, true );
891
892 // Only log important methods in detail
893 if ( $this->logging && $dat && isset( $dat['method'] ) ) {
894 $method = $dat['method'];
895 // Skip logging for repetitive/less important notifications
896 if ( !in_array( $method, ['notifications/initialized', 'notifications/cancelled'] ) ) {
897 error_log( '[AI Engine MCP] ↓ ' . $method );
898 }
899 }
900
901 if ( json_last_error() !== JSON_ERROR_NONE ) {
902 $this->queue_error( $sess, null, -32700, 'Parse error: invalid JSON' );
903 return new WP_REST_Response( null, 204 );
904 }
905 if ( !is_array( $dat ) ) {
906 $this->queue_error( $sess, null, -32600, 'Invalid Request' );
907 return new WP_REST_Response( null, 204 );
908 }
909
910 $id = $dat['id'] ?? null;
911 $method = $dat['method'] ?? null;
912
913 /* — notifications —*/
914 if ( $method === 'initialized' ) {
915 return new WP_REST_Response( null, 204 );
916 }
917 if ( $method === 'notifications/cancelled' ) {
918 // Agent finished - queue kill signal to close SSE immediately
919 if ( $this->logging ) {
920 error_log( '[AI Engine MCP] Agent cancelled - closing SSE connection' );
921 }
922 $this->store_message( $sess, [
923 'jsonrpc' => '2.0',
924 'method' => 'mwai/kill'
925 ] );
926 return new WP_REST_Response( null, 204 );
927 }
928 if ( $method === 'mwai/kill' ) {
929 // Kill signal received - no need for verbose logging
930 // Queue the kill message for SSE to pick up before exiting
931 $this->store_message( $sess, [
932 'jsonrpc' => '2.0',
933 'method' => 'mwai/kill'
934 ] );
935 // Give it a moment to be stored
936 usleep( 100000 ); // 100ms
937 return new WP_REST_Response( null, 204 );
938 }
939
940 // It's a notification, no ID = no reply
941 if ( $id === null && $method !== null ) {
942 return new WP_REST_Response( null, 204 );
943 }
944
945 if ( !$method ) {
946 $this->queue_error( $sess, $id, -32600, 'Invalid Request: method missing' );
947 return new WP_REST_Response( null, 204 );
948 }
949
950 try {
951
952 $reply = null;
953
954 #region Methods switch
955 switch ( $method ) {
956
957 case 'initialize':
958 // Check if client requests a specific protocol version
959 $params = $dat['params'] ?? [];
960 $requested_version = $params['protocolVersion'] ?? null;
961 $client_info = $params['clientInfo'] ?? null;
962
963 if ( $this->logging && $client_info ) {
964 $client_name = $client_info['name'] ?? 'unknown';
965 $client_version = $client_info['version'] ?? 'unknown';
966 error_log( "[AI Engine MCP] Client: {$client_name} v{$client_version}" );
967 }
968
969 if ( $requested_version && $requested_version !== $this->protocol_version ) {
970 if ( $this->logging ) {
971 Meow_MWAI_Logging::warn( "[AI Engine MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
972 }
973 }
974
975 $reply = [
976 'jsonrpc' => '2.0',
977 'id' => $id,
978 'result' => [
979 'protocolVersion' => $this->protocol_version,
980 'serverInfo' => (object) [
981 'name' => 'AI Engine - ' . get_bloginfo( 'name' ),
982 'version' => $this->server_version,
983 ],
984 'capabilities' => (object) [
985 'tools' => new stdClass(), // Empty object, matching official SDK
986 ],
987 ],
988 ];
989 break;
990
991 case 'tools/list':
992 $tools = $this->get_tools_list();
993
994 // Debug logging for tools/list
995 if ( $this->logging ) {
996 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
997 error_log( '[AI Engine MCP] 📋 tools/list requested by: ' . $user_agent );
998 error_log( '[AI Engine MCP] 📊 Returning ' . count( $tools ) . ' tools' );
999 if ( count( $tools ) > 0 ) {
1000 $tool_names = array_column( $tools, 'name' );
1001 error_log( '[AI Engine MCP] 🛠️ Tool names: ' . implode( ', ', $tool_names ) );
1002 }
1003 else {
1004 error_log( '[AI Engine MCP] ⚠️ WARNING: No tools returned!' );
1005 }
1006 }
1007
1008 $reply = [
1009 'jsonrpc' => '2.0',
1010 'id' => $id,
1011 'result' => [ 'tools' => $tools ],
1012 ];
1013 break;
1014
1015 case 'resources/list':
1016 $reply = [
1017 'jsonrpc' => '2.0',
1018 'id' => $id,
1019 'result' => [ 'resources' => $this->get_resources_list() ],
1020 ];
1021 break;
1022
1023 case 'prompts/list':
1024 $reply = [
1025 'jsonrpc' => '2.0',
1026 'id' => $id,
1027 'result' => [ 'prompts' => $this->get_prompts_list() ],
1028 ];
1029 break;
1030
1031 case 'tools/call':
1032 $params = $dat['params'] ?? [];
1033 $tool = $params['name'] ?? '';
1034 $arguments = $params['arguments'] ?? [];
1035
1036 if ( $this->logging ) {
1037 error_log( '[AI Engine MCP SSE] 🔧 tools/call - Tool: ' . $tool );
1038 error_log( '[AI Engine MCP SSE] 🔧 tools/call - Arguments: ' . wp_json_encode( $arguments ) );
1039 }
1040
1041 try {
1042 $reply = $this->execute_tool( $tool, $arguments, $id );
1043 if ( $this->logging ) {
1044 error_log( '[AI Engine MCP SSE] �
1045 tools/call - Success for tool: ' . $tool );
1046 }
1047 }
1048 catch ( Exception $e ) {
1049 if ( $this->logging ) {
1050 error_log( '[AI Engine MCP SSE] tools/call - Error: ' . $e->getMessage() );
1051 }
1052 throw $e;
1053 }
1054 break;
1055
1056 default:
1057 $reply = $this->rpc_error( $id, -32601, "Method not found: {$method}" );
1058 }
1059 #endregion
1060
1061 if ( $reply ) {
1062 // Don't log response queuing - it's too noisy
1063 $this->store_message( $sess, $reply );
1064 }
1065
1066 }
1067 catch ( Exception $e ) {
1068 $this->queue_error( $sess, $id, -32603, 'Internal error', $e->getMessage() );
1069 }
1070
1071 return new WP_REST_Response( null, 204 );
1072 }
1073 #endregion
1074
1075 #region Tools Definitions
1076 private function get_tools_list() {
1077 $base_tools = [
1078 [
1079 'name' => 'mcp_ping',
1080 'description' => 'Simple connectivity check. Returns the current GMT time and the WordPress site name. Whenever a tool call fails (error or timeout), immediately invoke mcp_ping to verify the server; if mcp_ping itself does not respond, assume the server is temporarily unreachable and pause additional tool calls.',
1081 'inputSchema' => [
1082 'type' => 'object',
1083 'properties' => (object) [],
1084 'required' => []
1085 ],
1086 'annotations' => [
1087 'readOnlyHint' => true,
1088 'destructiveHint' => false,
1089 'openWorldHint' => false,
1090 ],
1091 ],
1092 ];
1093
1094 if ( $this->logging ) {
1095 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Starting with ' . count( $base_tools ) . ' base tools' );
1096 }
1097
1098 $filtered_tools = apply_filters( 'mwai_mcp_tools', $base_tools );
1099
1100 if ( $this->logging ) {
1101 error_log( '[AI Engine MCP] 🔧 get_tools_list() - After filters: ' . count( $filtered_tools ) . ' tools' );
1102 }
1103
1104 $normalized_tools = [];
1105 foreach ( $filtered_tools as $tool_index => $tool_definition ) {
1106 $normalized = $this->normalize_tool_definition( $tool_definition, $tool_index );
1107 if ( $normalized ) {
1108 $normalized_tools[] = $normalized;
1109 }
1110 }
1111
1112 if ( $this->logging ) {
1113 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Normalized tools: ' . count( $normalized_tools ) );
1114 }
1115
1116 return $normalized_tools;
1117 }
1118 #endregion
1119
1120 #region Resources Definitions
1121 private function get_resources_list() {
1122 return [];
1123 }
1124 #endregion
1125
1126 #region Prompts Definitions
1127 private function get_prompts_list() {
1128 return [];
1129 }
1130 #endregion
1131
1132 #region Tool Normalization Helpers
1133 private function normalize_tool_definition( $tool, $index ) {
1134 if ( !is_array( $tool ) ) {
1135 if ( $this->logging ) {
1136 error_log( '[AI Engine MCP] ⚠️ Tool definition at index ' . $index . ' skipped (expected array).' );
1137 }
1138 return null;
1139 }
1140
1141 $name = isset( $tool['name'] ) ? trim( (string) $tool['name'] ) : '';
1142 if ( $name === '' ) {
1143 if ( $this->logging ) {
1144 error_log( '[AI Engine MCP] ⚠️ Tool skipped due to missing name at index ' . $index );
1145 }
1146 return null;
1147 }
1148
1149 $normalized_schema = $this->normalize_input_schema( $tool['inputSchema'] ?? null, $name );
1150 if ( !$normalized_schema ) {
1151 if ( $this->logging ) {
1152 error_log( '[AI Engine MCP] ⚠️ Tool "' . $name . '" skipped due to invalid input schema.' );
1153 }
1154 return null;
1155 }
1156
1157 $normalized = [
1158 'name' => $name,
1159 'inputSchema' => $normalized_schema,
1160 ];
1161
1162 if ( isset( $tool['description'] ) && $tool['description'] !== '' ) {
1163 $normalized['description'] = wp_strip_all_tags( (string) $tool['description'] );
1164 }
1165
1166 if ( isset( $tool['annotations'] ) && is_array( $tool['annotations'] ) ) {
1167 $annotations = $this->normalize_annotations( $tool['annotations'], $name );
1168 if ( !empty( $annotations ) ) {
1169 $normalized['annotations'] = $annotations;
1170 }
1171 }
1172
1173 if ( isset( $tool['category'] ) ) {
1174 $normalized['annotations'] = $normalized['annotations'] ?? [];
1175 if ( empty( $normalized['annotations']['title'] ) ) {
1176 $normalized['annotations']['title'] = wp_strip_all_tags( (string) $tool['category'] );
1177 }
1178 }
1179
1180 return $normalized;
1181 }
1182
1183 private function normalize_input_schema( $schema, string $tool_name ) {
1184 if ( !is_array( $schema ) ) {
1185 return null;
1186 }
1187
1188 $type = isset( $schema['type'] ) ? (string) $schema['type'] : 'object';
1189 if ( $type !== 'object' ) {
1190 if ( $this->logging ) {
1191 error_log( '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" has unsupported schema type: ' . $type );
1192 }
1193 return null;
1194 }
1195
1196 $properties = [];
1197 if ( isset( $schema['properties'] ) && ( is_array( $schema['properties'] ) || is_object( $schema['properties'] ) ) ) {
1198 foreach ( (array) $schema['properties'] as $prop_name => $definition ) {
1199 if ( !is_array( $definition ) ) {
1200 $definition = [];
1201 }
1202
1203 if ( isset( $definition['type'] ) ) {
1204 // Validate type definition
1205 if ( is_array( $definition['type'] ) ) {
1206 // Array of types (union types) - validate they're compatible with MCP clients
1207 $type_array = array_map( 'strval', $definition['type'] );
1208
1209 // Check for complex types that need additional schema details
1210 $complex_types = array_intersect( $type_array, [ 'object', 'array' ] );
1211 if ( !empty( $complex_types ) ) {
1212 if ( $this->logging ) {
1213 error_log(
1214 '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" property "' . $prop_name .
1215 '" has problematic union type with complex types: [' . implode( ', ', $type_array ) .
1216 ']. This breaks ChatGPT. Auto-fixing by removing type constraint.'
1217 );
1218 }
1219 // Auto-fix: Remove the type constraint to accept any value
1220 unset( $definition['type'] );
1221 // Keep description if present, or add one
1222 if ( !isset( $definition['description'] ) ) {
1223 $definition['description'] = 'Value can be of any type';
1224 }
1225 }
1226 else {
1227 $definition['type'] = $type_array;
1228 }
1229 }
1230 else {
1231 $definition['type'] = (string) $definition['type'];
1232 }
1233 }
1234
1235 $properties[ $prop_name ] = $definition;
1236 }
1237 }
1238
1239 $required = [];
1240 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
1241 foreach ( $schema['required'] as $field ) {
1242 $field_name = trim( (string) $field );
1243 if ( $field_name !== '' ) {
1244 $required[] = $field_name;
1245 }
1246 }
1247 $required = array_values( array_unique( $required ) );
1248 }
1249
1250 $normalized = [
1251 'type' => 'object',
1252 'properties' => empty( $properties ) ? new stdClass() : $properties,
1253 ];
1254
1255 if ( !empty( $required ) ) {
1256 $normalized['required'] = $required;
1257 }
1258
1259 if ( array_key_exists( 'additionalProperties', $schema ) ) {
1260 $normalized['additionalProperties'] = (bool) $schema['additionalProperties'];
1261 }
1262
1263 return $normalized;
1264 }
1265
1266 private function normalize_annotations( array $annotations, string $tool_name ): array {
1267 $allowed_keys = [ 'title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ];
1268 $normalized = [];
1269
1270 foreach ( $annotations as $key => $value ) {
1271 if ( !in_array( $key, $allowed_keys, true ) ) {
1272 continue;
1273 }
1274
1275 if ( in_array( $key, [ 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ], true ) ) {
1276 $normalized[ $key ] = (bool) $value;
1277 }
1278 elseif ( $key === 'title' ) {
1279 $normalized['title'] = wp_strip_all_tags( (string) $value );
1280 }
1281 }
1282
1283 if ( empty( $normalized ) && $this->logging && !empty( $annotations ) ) {
1284 error_log( '[AI Engine MCP] 🔎 Tool "' . $tool_name . '" included unsupported annotation keys.' );
1285 }
1286
1287 return $normalized;
1288 }
1289 #endregion
1290
1291 #region Tools Call (execute_tool)
1292 private function execute_tool( $tool, $args, $id ) {
1293 try {
1294 // Handle built-in tools first
1295 if ( $tool === 'mcp_ping' ) {
1296 if ( $this->logging ) {
1297 $this->log( '🛠️ Tool: mcp_ping' );
1298 }
1299 $ping_data = [
1300 'time' => gmdate( 'Y-m-d H:i:s' ),
1301 'name' => get_bloginfo( 'name' ),
1302 ];
1303 return [
1304 'jsonrpc' => '2.0',
1305 'id' => $id,
1306 'result' => [
1307 'content' => [
1308 [
1309 'type' => 'text',
1310 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
1311 ],
1312 ],
1313 'data' => $ping_data,
1314 ],
1315 ];
1316 }
1317
1318 // Let other modules handle their tools
1319 if ( $this->logging ) {
1320 // Log tool calls with more context
1321 $args_preview = '';
1322 if ( !empty( $args ) ) {
1323 // Show key args for common tools
1324 if ( isset( $args['ID'] ) ) {
1325 $args_preview = ' (ID: ' . $args['ID'] . ')';
1326 }
1327 elseif ( isset( $args['query'] ) ) {
1328 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
1329 }
1330 elseif ( isset( $args['message'] ) ) {
1331 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
1332 }
1333 }
1334 // Log to both error log and UI
1335 error_log( '[AI Engine MCP] 🛠️ ' . $tool . $args_preview );
1336 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
1337 }
1338 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
1339
1340 if ( $filtered !== null ) {
1341 // Check if it's already a full JSON-RPC response (backward compatibility)
1342 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
1343 return $filtered;
1344 }
1345
1346 // Otherwise, wrap the result in proper JSON-RPC format
1347 return [
1348 'jsonrpc' => '2.0',
1349 'id' => $id,
1350 'result' => $this->format_tool_result( $filtered ),
1351 ];
1352 }
1353
1354 throw new Exception( "Unknown tool: {$tool}" );
1355 }
1356 catch ( Exception $e ) {
1357 return $this->rpc_error( $id, -32603, $e->getMessage() );
1358 }
1359 }
1360 #endregion
1361
1362 #region Handle /upload (one-time file upload via token)
1363 public function handle_upload( WP_REST_Request $request ) {
1364 $token = $request->get_param( 'token' );
1365 if ( empty( $token ) ) {
1366 return new WP_REST_Response( [ 'success' => false, 'message' => 'Missing token.' ], 400 );
1367 }
1368
1369 $transient_key = 'mwai_mcp_upload_' . $token;
1370 $data = get_transient( $transient_key );
1371 if ( empty( $data ) ) {
1372 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid or expired upload token.' ], 403 );
1373 }
1374
1375 // Immediately delete the transient so the token can only be used once
1376 delete_transient( $transient_key );
1377
1378 $files = $request->get_file_params();
1379 if ( empty( $files['file'] ) ) {
1380 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided. Use: curl -X POST -F "file=@/path/to/file" "<url>"' ], 400 );
1381 }
1382
1383 $uploaded = $files['file'];
1384 if ( $uploaded['error'] !== UPLOAD_ERR_OK ) {
1385 return new WP_REST_Response( [ 'success' => false, 'message' => 'Upload error code: ' . $uploaded['error'] ], 400 );
1386 }
1387
1388 // Set admin context for media handling
1389 if ( !current_user_can( 'administrator' ) ) {
1390 wp_set_current_user( 1 );
1391 }
1392
1393 require_once ABSPATH . 'wp-admin/includes/file.php';
1394 require_once ABSPATH . 'wp-admin/includes/media.php';
1395 require_once ABSPATH . 'wp-admin/includes/image.php';
1396
1397 // Use the filename from the transient (sanitized at creation time)
1398 $file = [
1399 'name' => $data['filename'],
1400 'tmp_name' => $uploaded['tmp_name'],
1401 ];
1402
1403 $attachment_id = media_handle_sideload( $file, 0, $data['description'] );
1404 if ( is_wp_error( $attachment_id ) ) {
1405 return new WP_REST_Response( [ 'success' => false, 'message' => $attachment_id->get_error_message() ], 500 );
1406 }
1407
1408 if ( !empty( $data['title'] ) ) {
1409 wp_update_post( [ 'ID' => $attachment_id, 'post_title' => sanitize_text_field( $data['title'] ) ] );
1410 }
1411 if ( !empty( $data['alt'] ) ) {
1412 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $data['alt'] ) );
1413 }
1414
1415 return new WP_REST_Response( [
1416 'success' => true,
1417 'attachment_id' => $attachment_id,
1418 'url' => wp_get_attachment_url( $attachment_id ),
1419 ], 200 );
1420 }
1421 #endregion
1422
1423 #region Message Queue (per-message transient)
1424 private function transient_key( $sess, $id ) {
1425 return "{$this->queue_key}_{$sess}_{$id}";
1426 }
1427
1428 private function store_message( $sess, $payload ) {
1429 if ( !$sess ) {
1430 return;
1431 }
1432 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
1433 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
1434 $this->log( "queued #{$idKey}" );
1435 }
1436
1437 private function fetch_messages( $sess ) {
1438 global $wpdb;
1439 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
1440
1441 $rows = $wpdb->get_results(
1442 $wpdb->prepare(
1443 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
1444 $like
1445 ),
1446 ARRAY_A
1447 );
1448
1449 $msgs = [];
1450 foreach ( $rows as $r ) {
1451 $msgs[] = maybe_unserialize( $r['option_value'] );
1452 delete_option( $r['option_name'] );
1453 }
1454 usort( $msgs, fn ( $a, $b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
1455 if ( $msgs ) {
1456 $this->log( 'flush ' . count( $msgs ) . ' msg(s)' );
1457 }
1458 return $msgs;
1459 }
1460 #endregion
1461
1462 #region Resources (note)
1463 /*--------------------------------------------------*/
1464 /**
1465 * MCP also supports “resources” – static or dynamic data a client can
1466 * retrieve by URL (e.g. `mcp://resource/posts/123`).
1467 */
1468 #endregion
1469 }
1470