PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.3.3
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.3.3
3.6.2 3.6.1 3.6.0 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
1400 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 #endregion
145
146 #region Auth (Bearer token)
147 /**
148 * SECURITY: MCP provides powerful WordPress management capabilities, so access must be strictly controlled.
149 *
150 * By default, only administrators can access MCP endpoints. This prevents lower-privileged users
151 * (subscribers, contributors, etc.) from executing dangerous operations like creating admin users,
152 * deleting content, or modifying settings.
153 *
154 * When a bearer token is configured, it overrides the default admin check, but access is DENIED
155 * unless a valid token is provided. This ensures MCP is secure even with default settings.
156 */
157 public function can_access_mcp( $request ) {
158 // Default to requiring administrator capability for security
159 $is_admin = current_user_can( 'administrator' );
160 return apply_filters( 'mwai_allow_mcp', $is_admin, $request );
161 }
162
163 public function auth_via_bearer_token( $allow, $request ) {
164 // Skip if already authenticated as admin
165 if ( $allow ) {
166 return $allow;
167 }
168
169 $hdr = $request->get_header( 'authorization' );
170
171 // If no authorization header but bearer token is configured, deny access
172 if ( !$hdr && !empty( $this->bearer_token ) ) {
173 if ( $this->logging ) {
174 error_log( '[AI Engine MCP] ❌ No authorization header provided. Server may be stripping headers.' );
175 }
176 return false;
177 }
178
179 // Check for Bearer token in header
180 if ( $hdr && preg_match( '/Bearer\s+(.+)/i', $hdr, $m ) ) {
181 $token = trim( $m[1] );
182 $auth_result = 'none';
183
184 // Check if it's an OAuth token
185 if ( $this->oauth ) {
186 $token_data = $this->oauth->validate_token( $token );
187 if ( $token_data ) {
188 // Set current user based on OAuth token
189 wp_set_current_user( $token_data['user_id'] );
190 $auth_result = 'oauth';
191 // Only log auth for SSE endpoint
192 if ( $this->logging && strpos( $request->get_route(), '/sse' ) !== false ) {
193 error_log( '[AI Engine MCP] 🔐 OAuth OK (user: ' . $token_data['user_id'] . ')' );
194 }
195 return true;
196 }
197 }
198
199 // Fall back to static bearer token if configured
200 if ( !empty( $this->bearer_token ) && hash_equals( $this->bearer_token, $token ) ) {
201 if ( $admin = $this->core->get_admin_user() ) {
202 wp_set_current_user( $admin->ID, $admin->user_login );
203 }
204 $auth_result = 'static';
205 if ( $this->logging ) {
206 error_log( '[AI Engine MCP] 🔐 Bearer token auth OK' );
207 }
208 return true;
209 }
210
211 if ( $this->logging && $auth_result === 'none' ) {
212 error_log( '[AI Engine MCP] ❌ Bearer token invalid.' );
213 }
214 // Explicitly deny access for invalid tokens
215 return false;
216 }
217
218 // ?token=xyz fallback (optional) - only for static bearer token
219 if ( !empty( $this->bearer_token ) ) {
220 $q = sanitize_text_field( $request->get_param( 'token' ) );
221 if ( $q && hash_equals( $this->bearer_token, $q ) ) {
222 if ( $admin = $this->core->get_admin_user() ) {
223 wp_set_current_user( $admin->ID, $admin->user_login );
224 }
225 return true;
226 }
227 }
228
229 // If bearer token is configured but no valid auth provided, deny access
230 if ( !empty( $this->bearer_token ) ) {
231 return false;
232 }
233
234 return $allow;
235 }
236
237 public function handle_noauth_access( $request ) {
238 // For no-auth URLs, the token is already verified by being in the URL path
239 // Double-check that the route actually contains the token
240 $route = $request->get_route();
241 if ( strpos( $route, '/' . $this->bearer_token . '/' ) === false ) {
242 if ( $this->logging ) {
243 error_log( '[AI Engine MCP] ❌ Invalid no-auth URL access attempt.' );
244 }
245 return false;
246 }
247
248 // Set the current user to admin since token is valid
249 if ( $admin = $this->core->get_admin_user() ) {
250 wp_set_current_user( $admin->ID, $admin->user_login );
251 }
252 return true;
253 }
254
255 public function handle_noauth_access_streamable( $request ) {
256 // For Streamable HTTP with token in URL path (no trailing slash)
257 $route = $request->get_route();
258 $expected = '/' . $this->namespace . '/' . $this->bearer_token;
259 if ( $route !== $expected ) {
260 if ( $this->logging ) {
261 error_log( '[AI Engine MCP] ❌ Invalid Streamable HTTP no-auth URL access attempt.' );
262 }
263 return false;
264 }
265
266 // Set the current user to admin since token is valid
267 if ( $admin = $this->core->get_admin_user() ) {
268 wp_set_current_user( $admin->ID, $admin->user_login );
269 }
270 return true;
271 }
272
273 #endregion
274
275 #region Helpers (log / JSON-RPC utils)
276 private function log( $msg ) {
277 // This method is for internal UI logs - keep it minimal
278 if ( $this->logging ) {
279 // Only log important messages to UI
280 if ( strpos( $msg, 'queued' ) === false && strpos( $msg, 'flush' ) === false ) {
281 Meow_MWAI_Logging::log( "[AI Engine MCP] {$msg}" );
282 }
283 }
284 }
285
286 /** Wrap a JSON-RPC error object */
287 private function rpc_error( $id, int $code, string $msg, $extra = null ): array {
288 $err = [ 'code' => $code, 'message' => $msg ];
289 if ( $extra !== null ) {
290 $err['data'] = $extra;
291 }
292 return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ];
293 }
294
295 /** Queue an error for SSE delivery */
296 private function queue_error( $sess, $id, int $code, string $msg, $extra = null ): void {
297 $this->store_message( $sess, $this->rpc_error( $id, $code, $msg, $extra ) );
298 }
299
300 /** Format tool result for MCP protocol */
301 private function format_tool_result( $result ): array {
302 // If result is a string, wrap it in the MCP content format
303 if ( is_string( $result ) ) {
304 return [
305 'content' => [
306 [
307 'type' => 'text',
308 'text' => $result,
309 ],
310 ],
311 ];
312 }
313
314 // If result has 'content' key, assume it's already properly formatted
315 if ( is_array( $result ) && isset( $result['content'] ) ) {
316 return $result;
317 }
318
319 // If result is an array without 'content' key, wrap it as JSON
320 if ( is_array( $result ) ) {
321 return [
322 'content' => [
323 [
324 'type' => 'text',
325 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT ),
326 ],
327 ],
328 'data' => $result,
329 ];
330 }
331
332 // For any other type, convert to string and wrap
333 return [
334 'content' => [
335 [
336 'type' => 'text',
337 'text' => (string) $result,
338 ],
339 ],
340 ];
341 }
342 #endregion
343
344 #region Handle direct JSON-RPC (for Claude's MCP client)
345 /**
346 * Claude's MCP client (via Anthropic API) sends JSON-RPC requests directly to the SSE endpoint
347 * as POST requests, rather than following the typical SSE flow:
348 * - Normal flow: GET /sse → establish SSE stream → POST /messages for JSON-RPC
349 * - Claude's flow: POST /sse with JSON-RPC body → expect immediate JSON response
350 *
351 * This method handles the direct JSON-RPC requests to maintain compatibility with Claude.
352 */
353 private function handle_direct_jsonrpc( WP_REST_Request $request, $data ) {
354 $id = $data['id'] ?? null;
355 $method = $data['method'] ?? null;
356
357 if ( json_last_error() !== JSON_ERROR_NONE ) {
358 $response = new WP_REST_Response( [
359 'jsonrpc' => '2.0',
360 'id' => null,
361 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
362 ], 200 );
363 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
364 $session_header = $request->get_header( 'mcp-session-id' );
365 if ( !empty( $session_header ) ) {
366 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
367 }
368 return $response;
369 }
370
371 if ( !is_array( $data ) || !$method ) {
372 $response = new WP_REST_Response( [
373 'jsonrpc' => '2.0',
374 'id' => $id,
375 'error' => [ 'code' => -32600, 'message' => 'Invalid Request' ]
376 ], 200 );
377 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
378 $session_header = $request->get_header( 'mcp-session-id' );
379 if ( !empty( $session_header ) ) {
380 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
381 }
382 return $response;
383 }
384
385 $session_header = $request->get_header( 'mcp-session-id' );
386 $session_id = '';
387 if ( !empty( $session_header ) ) {
388 $session_id = sanitize_text_field( $session_header );
389 }
390
391 if ( $method === 'initialize' || empty( $session_id ) ) {
392 $session_id = wp_generate_uuid4();
393 if ( $this->logging ) {
394 error_log( '[AI Engine MCP] 🆔 Direct session initialized: ' . $session_id );
395 }
396 }
397
398 try {
399 $reply = null;
400
401 switch ( $method ) {
402 case 'initialize':
403 // Check if client requests a specific protocol version
404 $params = $data['params'] ?? [];
405 $requested_version = $params['protocolVersion'] ?? null;
406 $client_info = $params['clientInfo'] ?? null;
407
408 if ( $this->logging && $client_info ) {
409 $client_name = $client_info['name'] ?? 'unknown';
410 $client_version = $client_info['version'] ?? 'unknown';
411 error_log( "[AI Engine MCP] Client: {$client_name} v{$client_version}" );
412 }
413
414 if ( $requested_version && $requested_version !== $this->protocol_version ) {
415 if ( $this->logging ) {
416 Meow_MWAI_Logging::warn( "[AI Engine MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
417 }
418 }
419
420 $reply = [
421 'jsonrpc' => '2.0',
422 'id' => $id,
423 'result' => [
424 'protocolVersion' => $this->protocol_version,
425 'serverInfo' => (object) [
426 'name' => 'AI Engine - ' . get_bloginfo( 'name' ),
427 'version' => $this->server_version,
428 ],
429 'capabilities' => (object) [
430 'tools' => new stdClass(), // Empty object, matching official SDK
431 ],
432 ],
433 ];
434 break;
435
436 case 'tools/list':
437 $tools = $this->get_tools_list();
438
439 // Debug logging for tools/list
440 if ( $this->logging ) {
441 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
442 error_log( '[AI Engine MCP Direct] 📋 tools/list requested by: ' . $user_agent );
443 error_log( '[AI Engine MCP Direct] 📊 Returning ' . count( $tools ) . ' tools' );
444 if ( count( $tools ) > 0 ) {
445 $tool_names = array_column( $tools, 'name' );
446 error_log( '[AI Engine MCP Direct] 🛠️ Tool names: ' . implode( ', ', $tool_names ) );
447 }
448 else {
449 error_log( '[AI Engine MCP Direct] ⚠️ WARNING: No tools returned!' );
450 }
451 }
452
453 $reply = [
454 'jsonrpc' => '2.0',
455 'id' => $id,
456 'result' => [ 'tools' => $tools ],
457 ];
458 break;
459
460 case 'tools/call':
461 $params = $data['params'] ?? [];
462 $tool = $params['name'] ?? '';
463 $arguments = $params['arguments'] ?? [];
464
465 if ( $this->logging ) {
466 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Tool: ' . $tool );
467 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Arguments: ' . wp_json_encode( $arguments ) );
468 }
469
470 try {
471 $reply = $this->execute_tool( $tool, $arguments, $id );
472 if ( $this->logging ) {
473 error_log( '[AI Engine MCP Direct] �
474 tools/call - Success for tool: ' . $tool );
475 }
476 }
477 catch ( Exception $e ) {
478 if ( $this->logging ) {
479 error_log( '[AI Engine MCP Direct] tools/call - Error: ' . $e->getMessage() );
480 }
481 throw $e;
482 }
483 break;
484
485 case 'notifications/initialized':
486 // This is a notification from the client indicating it has initialized
487 // No response needed for notifications
488 // Client initialized - no need to log
489 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
490 break;
491
492 default:
493 // Check if it's a notification (no id)
494 if ( $id === null && strpos( $method, 'notifications/' ) === 0 ) {
495 if ( $this->logging ) {
496 error_log( '[AI Engine MCP] 📨 Notification received: ' . $method );
497 }
498 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
499 }
500
501 $reply = [
502 'jsonrpc' => '2.0',
503 'id' => $id,
504 'error' => [ 'code' => -32601, 'message' => "Method not found: {$method}" ]
505 ];
506 }
507
508 // Ensure proper JSON-RPC response
509 $response = new WP_REST_Response( $reply, 200 );
510 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
511 return $this->attach_session_header( $response, $session_id );
512
513 }
514 catch ( Exception $e ) {
515 if ( $this->logging ) {
516 error_log( '[AI Engine MCP] ❌ Exception in handle_direct_jsonrpc: ' . $e->getMessage() );
517 }
518
519 $error_response = new WP_REST_Response( [
520 'jsonrpc' => '2.0',
521 'id' => $id,
522 'error' => [ 'code' => -32603, 'message' => 'Internal error', 'data' => $e->getMessage() ]
523 ], 200 );
524 $error_response->set_headers( [ 'Content-Type' => 'application/json' ] );
525 return $this->attach_session_header( $error_response, $session_id );
526 }
527 }
528 #endregion
529
530 #region Handle SSE (stream loop)
531 private function reply( string $event, $data = null, string $enc = 'json' ) {
532 // Handle special events
533 if ( $event === 'bye' ) {
534 echo "event: bye\ndata: \n\n";
535 if ( ob_get_level() ) {
536 ob_end_flush();
537 }
538 flush();
539 $this->last_action_time = time();
540 $this->log( 'Clean disconnection' );
541 return;
542 }
543
544 if ( $enc === 'json' && $data === null ) {
545 $this->log( "no data for {$event}" );
546 return;
547 }
548 echo "event: {$event}\n";
549 if ( $enc === 'json' ) {
550 $data = $data === null ? '{}' : wp_json_encode( $data, JSON_UNESCAPED_UNICODE );
551 }
552 echo 'data: ' . $data . "\n\n";
553
554 if ( ob_get_level() ) {
555 ob_end_flush();
556 }
557 flush();
558
559 $this->last_action_time = time();
560 // Only log endpoint announcements
561 if ( $event === 'endpoint' ) {
562 $this->log( 'SSE endpoint ready' );
563 }
564 }
565
566 private function generate_sse_id( $req ) {
567 $last = $req ? $req->get_header( 'last-event-id' ) : '';
568 return $last ?: str_replace( '-', '', wp_generate_uuid4() );
569 }
570
571 private function attach_session_header( WP_REST_Response $response, string $session_id ) {
572 if ( empty( $session_id ) ) {
573 return $response;
574 }
575
576 $response->header( 'Mcp-Session-Id', $session_id );
577
578 if ( $this->logging ) {
579 error_log( '[AI Engine MCP] 🪪 Response session header: ' . $session_id );
580 }
581
582 return $response;
583 }
584
585 public function handle_sse( WP_REST_Request $request ) {
586 // Handle HEAD request - just confirm endpoint exists
587 if ( $request->get_method() === 'HEAD' ) {
588 return new WP_REST_Response( null, 200, [
589 'Content-Type' => 'text/event-stream',
590 'Cache-Control' => 'no-cache',
591 ] );
592 }
593
594 $raw_body = $request->get_body();
595
596 // Handle POST request with JSON-RPC body (Direct MCP client behavior)
597 // Both Claude.ai and OpenAI/ChatGPT send JSON-RPC requests directly to the SSE endpoint
598 // instead of establishing an SSE connection first. This is non-standard but we need to support it.
599 // Expected flow: GET /sse (establish stream) → POST /messages (send JSON-RPC)
600 // Actual flow: POST /sse with JSON-RPC body → expects immediate JSON response
601 if ( $request->get_method() === 'POST' && !empty( $raw_body ) ) {
602 $data = json_decode( $raw_body, true );
603 if ( $data && isset( $data['method'] ) ) {
604 // Don't log here - it's already logged by log_requests()
605 // Process as a direct JSON-RPC request instead of starting SSE stream
606 return $this->handle_direct_jsonrpc( $request, $data );
607 }
608 }
609
610 @ini_set( 'zlib.output_compression', '0' );
611 @ini_set( 'output_buffering', '0' );
612 @ini_set( 'implicit_flush', '1' );
613 if ( function_exists( 'ob_implicit_flush' ) ) {
614 ob_implicit_flush( true );
615 }
616
617 header( 'Content-Type: text/event-stream' );
618 header( 'Cache-Control: no-cache' );
619 header( 'X-Accel-Buffering: no' );
620 header( 'Connection: keep-alive' );
621 while ( ob_get_level() ) {
622 ob_end_flush();
623 }
624
625 /* — greet client —*/
626 $this->session_id = $this->generate_sse_id( $request );
627 $this->last_action_time = time();
628 echo "id: {$this->session_id}\n\n";
629 flush();
630
631 $msg_uri = sprintf(
632 '%s/messages?session_id=%s',
633 rest_url( $this->namespace ),
634 $this->session_id
635 );
636 $this->reply( 'endpoint', $msg_uri, 'text' );
637 if ( $this->logging ) {
638 error_log( '[AI Engine MCP] �
639 SSE connected (' . substr( $this->session_id, 0, 8 ) . '...)' );
640 }
641
642 /* — main loop —*/
643 while ( true ) {
644 // Reduced timeout to free workers faster when agents disconnect
645 $max_time = $this->logging ? 30 : 60 * 3; // 30 seconds in debug, 3 minutes in production
646 $idle = ( time() - $this->last_action_time ) >= $max_time;
647 if ( connection_aborted() || $idle ) {
648 $this->reply( 'bye' );
649 if ( $this->logging ) {
650 error_log( '[AI Engine MCP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
651 }
652 break;
653 }
654
655 // Send heartbeat every 10 seconds to detect dead connections
656 $time_since_last = time() - $this->last_action_time;
657 if ( $time_since_last >= 10 && $time_since_last % 10 === 0 ) {
658 echo ": heartbeat\n\n";
659 if ( ob_get_level() ) {
660 ob_end_flush();
661 }
662 flush();
663 }
664
665 foreach ( $this->fetch_messages( $this->session_id ) as $p ) {
666 // Check for kill signal in the message queue
667 if ( isset( $p['method'] ) && $p['method'] === 'mwai/kill' ) {
668 if ( $this->logging ) {
669 error_log( '[AI Engine MCP] Kill signal - terminating' );
670 }
671 $this->reply( 'bye' );
672 exit;
673 }
674
675 // Don't log SSE responses - they clutter the logs
676 $this->reply( 'message', $p );
677 }
678
679 usleep( 200000 ); // 200 ms
680 }
681 exit;
682 }
683 #endregion
684
685 #region Handle Streamable HTTP (Modern MCP transport)
686 /**
687 * Handle Streamable HTTP requests per MCP specification.
688 * This is the modern transport used by Claude Code and other MCP clients.
689 *
690 * - POST: Send JSON-RPC request, receive JSON response (or SSE for streaming)
691 * - GET: Open SSE stream for server-initiated messages
692 * - DELETE: Terminate the session
693 *
694 * @see https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
695 */
696 public function handle_streamable_http( WP_REST_Request $request ) {
697 $method = $request->get_method();
698
699 switch ( $method ) {
700 case 'POST':
701 return $this->handle_streamable_http_post( $request );
702
703 case 'GET':
704 return $this->handle_streamable_http_get( $request );
705
706 case 'DELETE':
707 return $this->handle_streamable_http_delete( $request );
708
709 default:
710 return new WP_REST_Response( [
711 'error' => 'Method not allowed'
712 ], 405 );
713 }
714 }
715
716 /**
717 * Handle POST requests for Streamable HTTP.
718 * This processes JSON-RPC requests and returns JSON responses.
719 */
720 private function handle_streamable_http_post( WP_REST_Request $request ) {
721 $raw_body = $request->get_body();
722
723 if ( empty( $raw_body ) ) {
724 return new WP_REST_Response( [
725 'jsonrpc' => '2.0',
726 'id' => null,
727 'error' => [ 'code' => -32700, 'message' => 'Parse error: empty body' ]
728 ], 400 );
729 }
730
731 $data = json_decode( $raw_body, true );
732
733 if ( json_last_error() !== JSON_ERROR_NONE ) {
734 return new WP_REST_Response( [
735 'jsonrpc' => '2.0',
736 'id' => null,
737 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
738 ], 400 );
739 }
740
741 // Log the request if debugging is enabled
742 if ( $this->logging && isset( $data['method'] ) ) {
743 error_log( '[AI Engine MCP HTTP] ↓ ' . $data['method'] );
744 }
745
746 // Reuse the existing direct JSON-RPC handler
747 return $this->handle_direct_jsonrpc( $request, $data );
748 }
749
750 /**
751 * Handle GET requests for Streamable HTTP.
752 * This opens an SSE stream for server-to-client messages.
753 * Used when the server needs to send notifications or progress updates.
754 */
755 private function handle_streamable_http_get( WP_REST_Request $request ) {
756 // Check Accept header - must accept text/event-stream
757 $accept = $request->get_header( 'accept' );
758 if ( strpos( $accept, 'text/event-stream' ) === false ) {
759 return new WP_REST_Response( [
760 'error' => 'Accept header must include text/event-stream'
761 ], 406 );
762 }
763
764 // Get or create session ID
765 $session_header = $request->get_header( 'mcp-session-id' );
766 $session_id = !empty( $session_header ) ? sanitize_text_field( $session_header ) : wp_generate_uuid4();
767
768 if ( $this->logging ) {
769 error_log( '[AI Engine MCP HTTP] 📡 SSE stream opened for session: ' . substr( $session_id, 0, 8 ) . '...' );
770 }
771
772 // Set up SSE output
773 @ini_set( 'zlib.output_compression', '0' );
774 @ini_set( 'output_buffering', '0' );
775 @ini_set( 'implicit_flush', '1' );
776 if ( function_exists( 'ob_implicit_flush' ) ) {
777 ob_implicit_flush( true );
778 }
779
780 header( 'Content-Type: text/event-stream' );
781 header( 'Cache-Control: no-cache' );
782 header( 'X-Accel-Buffering: no' );
783 header( 'Connection: keep-alive' );
784 header( 'Mcp-Session-Id: ' . $session_id );
785
786 while ( ob_get_level() ) {
787 ob_end_flush();
788 }
789
790 $this->session_id = $session_id;
791 $this->last_action_time = time();
792
793 // Send initial connection event
794 echo "event: open\n";
795 echo 'data: {"session":"' . esc_js( $session_id ) . "\"}\n\n";
796 flush();
797
798 // Main SSE loop - listen for server-initiated messages
799 while ( true ) {
800 $max_time = $this->logging ? 30 : 60 * 3;
801 $idle = ( time() - $this->last_action_time ) >= $max_time;
802
803 if ( connection_aborted() || $idle ) {
804 if ( $this->logging ) {
805 error_log( '[AI Engine MCP HTTP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
806 }
807 break;
808 }
809
810 // Check for queued messages
811 foreach ( $this->fetch_messages( $session_id ) as $msg ) {
812 if ( isset( $msg['method'] ) && $msg['method'] === 'mwai/kill' ) {
813 echo "event: close\ndata: {}\n\n";
814 flush();
815 exit;
816 }
817
818 echo "event: message\n";
819 echo 'data: ' . wp_json_encode( $msg, JSON_UNESCAPED_UNICODE ) . "\n\n";
820 flush();
821 $this->last_action_time = time();
822 }
823
824 // Heartbeat every 10 seconds
825 $time_since_last = time() - $this->last_action_time;
826 if ( $time_since_last >= 10 && $time_since_last % 10 === 0 ) {
827 echo ": heartbeat\n\n";
828 flush();
829 }
830
831 usleep( 200000 ); // 200ms
832 }
833
834 exit;
835 }
836
837 /**
838 * Handle DELETE requests for Streamable HTTP.
839 * This terminates the session and cleans up any resources.
840 */
841 private function handle_streamable_http_delete( WP_REST_Request $request ) {
842 $session_header = $request->get_header( 'mcp-session-id' );
843
844 if ( empty( $session_header ) ) {
845 return new WP_REST_Response( [
846 'error' => 'Mcp-Session-Id header required'
847 ], 400 );
848 }
849
850 $session_id = sanitize_text_field( $session_header );
851
852 if ( $this->logging ) {
853 error_log( '[AI Engine MCP HTTP] 🗑️ Session terminated: ' . substr( $session_id, 0, 8 ) . '...' );
854 }
855
856 // Queue kill signal for any active SSE streams
857 $this->store_message( $session_id, [
858 'jsonrpc' => '2.0',
859 'method' => 'mwai/kill'
860 ] );
861
862 // Clean up any remaining transients for this session
863 global $wpdb;
864 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$session_id}_" ) . '%';
865 $wpdb->query(
866 $wpdb->prepare(
867 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
868 $like
869 )
870 );
871
872 // Return 204 No Content on successful termination
873 return new WP_REST_Response( null, 204 );
874 }
875 #endregion
876
877 #region Handle /messages (JSON-RPC ingress)
878 public function handle_message( WP_REST_Request $request ) {
879 $sess = sanitize_text_field( $request->get_param( 'session_id' ) );
880 $raw = $request->get_body();
881 $dat = json_decode( $raw, true );
882
883 // Only log important methods in detail
884 if ( $this->logging && $dat && isset( $dat['method'] ) ) {
885 $method = $dat['method'];
886 // Skip logging for repetitive/less important notifications
887 if ( !in_array( $method, ['notifications/initialized', 'notifications/cancelled'] ) ) {
888 error_log( '[AI Engine MCP] ↓ ' . $method );
889 }
890 }
891
892 if ( json_last_error() !== JSON_ERROR_NONE ) {
893 $this->queue_error( $sess, null, -32700, 'Parse error: invalid JSON' );
894 return new WP_REST_Response( null, 204 );
895 }
896 if ( !is_array( $dat ) ) {
897 $this->queue_error( $sess, null, -32600, 'Invalid Request' );
898 return new WP_REST_Response( null, 204 );
899 }
900
901 $id = $dat['id'] ?? null;
902 $method = $dat['method'] ?? null;
903
904 /* — notifications —*/
905 if ( $method === 'initialized' ) {
906 return new WP_REST_Response( null, 204 );
907 }
908 if ( $method === 'notifications/cancelled' ) {
909 // Agent finished - queue kill signal to close SSE immediately
910 if ( $this->logging ) {
911 error_log( '[AI Engine MCP] Agent cancelled - closing SSE connection' );
912 }
913 $this->store_message( $sess, [
914 'jsonrpc' => '2.0',
915 'method' => 'mwai/kill'
916 ] );
917 return new WP_REST_Response( null, 204 );
918 }
919 if ( $method === 'mwai/kill' ) {
920 // Kill signal received - no need for verbose logging
921 // Queue the kill message for SSE to pick up before exiting
922 $this->store_message( $sess, [
923 'jsonrpc' => '2.0',
924 'method' => 'mwai/kill'
925 ] );
926 // Give it a moment to be stored
927 usleep( 100000 ); // 100ms
928 return new WP_REST_Response( null, 204 );
929 }
930
931 // It's a notification, no ID = no reply
932 if ( $id === null && $method !== null ) {
933 return new WP_REST_Response( null, 204 );
934 }
935
936 if ( !$method ) {
937 $this->queue_error( $sess, $id, -32600, 'Invalid Request: method missing' );
938 return new WP_REST_Response( null, 204 );
939 }
940
941 try {
942
943 $reply = null;
944
945 #region Methods switch
946 switch ( $method ) {
947
948 case 'initialize':
949 // Check if client requests a specific protocol version
950 $params = $dat['params'] ?? [];
951 $requested_version = $params['protocolVersion'] ?? null;
952 $client_info = $params['clientInfo'] ?? null;
953
954 if ( $this->logging && $client_info ) {
955 $client_name = $client_info['name'] ?? 'unknown';
956 $client_version = $client_info['version'] ?? 'unknown';
957 error_log( "[AI Engine MCP] Client: {$client_name} v{$client_version}" );
958 }
959
960 if ( $requested_version && $requested_version !== $this->protocol_version ) {
961 if ( $this->logging ) {
962 Meow_MWAI_Logging::warn( "[AI Engine MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
963 }
964 }
965
966 $reply = [
967 'jsonrpc' => '2.0',
968 'id' => $id,
969 'result' => [
970 'protocolVersion' => $this->protocol_version,
971 'serverInfo' => (object) [
972 'name' => 'AI Engine - ' . get_bloginfo( 'name' ),
973 'version' => $this->server_version,
974 ],
975 'capabilities' => (object) [
976 'tools' => new stdClass(), // Empty object, matching official SDK
977 ],
978 ],
979 ];
980 break;
981
982 case 'tools/list':
983 $tools = $this->get_tools_list();
984
985 // Debug logging for tools/list
986 if ( $this->logging ) {
987 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
988 error_log( '[AI Engine MCP] 📋 tools/list requested by: ' . $user_agent );
989 error_log( '[AI Engine MCP] 📊 Returning ' . count( $tools ) . ' tools' );
990 if ( count( $tools ) > 0 ) {
991 $tool_names = array_column( $tools, 'name' );
992 error_log( '[AI Engine MCP] 🛠️ Tool names: ' . implode( ', ', $tool_names ) );
993 }
994 else {
995 error_log( '[AI Engine MCP] ⚠️ WARNING: No tools returned!' );
996 }
997 }
998
999 $reply = [
1000 'jsonrpc' => '2.0',
1001 'id' => $id,
1002 'result' => [ 'tools' => $tools ],
1003 ];
1004 break;
1005
1006 case 'resources/list':
1007 $reply = [
1008 'jsonrpc' => '2.0',
1009 'id' => $id,
1010 'result' => [ 'resources' => $this->get_resources_list() ],
1011 ];
1012 break;
1013
1014 case 'prompts/list':
1015 $reply = [
1016 'jsonrpc' => '2.0',
1017 'id' => $id,
1018 'result' => [ 'prompts' => $this->get_prompts_list() ],
1019 ];
1020 break;
1021
1022 case 'tools/call':
1023 $params = $dat['params'] ?? [];
1024 $tool = $params['name'] ?? '';
1025 $arguments = $params['arguments'] ?? [];
1026
1027 if ( $this->logging ) {
1028 error_log( '[AI Engine MCP SSE] 🔧 tools/call - Tool: ' . $tool );
1029 error_log( '[AI Engine MCP SSE] 🔧 tools/call - Arguments: ' . wp_json_encode( $arguments ) );
1030 }
1031
1032 try {
1033 $reply = $this->execute_tool( $tool, $arguments, $id );
1034 if ( $this->logging ) {
1035 error_log( '[AI Engine MCP SSE] �
1036 tools/call - Success for tool: ' . $tool );
1037 }
1038 }
1039 catch ( Exception $e ) {
1040 if ( $this->logging ) {
1041 error_log( '[AI Engine MCP SSE] tools/call - Error: ' . $e->getMessage() );
1042 }
1043 throw $e;
1044 }
1045 break;
1046
1047 default:
1048 $reply = $this->rpc_error( $id, -32601, "Method not found: {$method}" );
1049 }
1050 #endregion
1051
1052 if ( $reply ) {
1053 // Don't log response queuing - it's too noisy
1054 $this->store_message( $sess, $reply );
1055 }
1056
1057 }
1058 catch ( Exception $e ) {
1059 $this->queue_error( $sess, $id, -32603, 'Internal error', $e->getMessage() );
1060 }
1061
1062 return new WP_REST_Response( null, 204 );
1063 }
1064 #endregion
1065
1066 #region Tools Definitions
1067 private function get_tools_list() {
1068 $base_tools = [
1069 [
1070 'name' => 'mcp_ping',
1071 '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.',
1072 'inputSchema' => [
1073 'type' => 'object',
1074 'properties' => (object) [],
1075 'required' => []
1076 ],
1077 'annotations' => [
1078 'readOnlyHint' => true,
1079 'destructiveHint' => false,
1080 'openWorldHint' => false,
1081 ],
1082 ],
1083 ];
1084
1085 if ( $this->logging ) {
1086 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Starting with ' . count( $base_tools ) . ' base tools' );
1087 }
1088
1089 $filtered_tools = apply_filters( 'mwai_mcp_tools', $base_tools );
1090
1091 if ( $this->logging ) {
1092 error_log( '[AI Engine MCP] 🔧 get_tools_list() - After filters: ' . count( $filtered_tools ) . ' tools' );
1093 }
1094
1095 $normalized_tools = [];
1096 foreach ( $filtered_tools as $tool_index => $tool_definition ) {
1097 $normalized = $this->normalize_tool_definition( $tool_definition, $tool_index );
1098 if ( $normalized ) {
1099 $normalized_tools[] = $normalized;
1100 }
1101 }
1102
1103 if ( $this->logging ) {
1104 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Normalized tools: ' . count( $normalized_tools ) );
1105 }
1106
1107 return $normalized_tools;
1108 }
1109 #endregion
1110
1111 #region Resources Definitions
1112 private function get_resources_list() {
1113 return [];
1114 }
1115 #endregion
1116
1117 #region Prompts Definitions
1118 private function get_prompts_list() {
1119 return [];
1120 }
1121 #endregion
1122
1123 #region Tool Normalization Helpers
1124 private function normalize_tool_definition( $tool, $index ) {
1125 if ( !is_array( $tool ) ) {
1126 if ( $this->logging ) {
1127 error_log( '[AI Engine MCP] ⚠️ Tool definition at index ' . $index . ' skipped (expected array).' );
1128 }
1129 return null;
1130 }
1131
1132 $name = isset( $tool['name'] ) ? trim( (string) $tool['name'] ) : '';
1133 if ( $name === '' ) {
1134 if ( $this->logging ) {
1135 error_log( '[AI Engine MCP] ⚠️ Tool skipped due to missing name at index ' . $index );
1136 }
1137 return null;
1138 }
1139
1140 $normalized_schema = $this->normalize_input_schema( $tool['inputSchema'] ?? null, $name );
1141 if ( !$normalized_schema ) {
1142 if ( $this->logging ) {
1143 error_log( '[AI Engine MCP] ⚠️ Tool "' . $name . '" skipped due to invalid input schema.' );
1144 }
1145 return null;
1146 }
1147
1148 $normalized = [
1149 'name' => $name,
1150 'inputSchema' => $normalized_schema,
1151 ];
1152
1153 if ( isset( $tool['description'] ) && $tool['description'] !== '' ) {
1154 $normalized['description'] = wp_strip_all_tags( (string) $tool['description'] );
1155 }
1156
1157 if ( isset( $tool['annotations'] ) && is_array( $tool['annotations'] ) ) {
1158 $annotations = $this->normalize_annotations( $tool['annotations'], $name );
1159 if ( !empty( $annotations ) ) {
1160 $normalized['annotations'] = $annotations;
1161 }
1162 }
1163
1164 if ( isset( $tool['category'] ) ) {
1165 $normalized['annotations'] = $normalized['annotations'] ?? [];
1166 if ( empty( $normalized['annotations']['title'] ) ) {
1167 $normalized['annotations']['title'] = wp_strip_all_tags( (string) $tool['category'] );
1168 }
1169 }
1170
1171 return $normalized;
1172 }
1173
1174 private function normalize_input_schema( $schema, string $tool_name ) {
1175 if ( !is_array( $schema ) ) {
1176 return null;
1177 }
1178
1179 $type = isset( $schema['type'] ) ? (string) $schema['type'] : 'object';
1180 if ( $type !== 'object' ) {
1181 if ( $this->logging ) {
1182 error_log( '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" has unsupported schema type: ' . $type );
1183 }
1184 return null;
1185 }
1186
1187 $properties = [];
1188 if ( isset( $schema['properties'] ) && ( is_array( $schema['properties'] ) || is_object( $schema['properties'] ) ) ) {
1189 foreach ( (array) $schema['properties'] as $prop_name => $definition ) {
1190 if ( !is_array( $definition ) ) {
1191 $definition = [];
1192 }
1193
1194 if ( isset( $definition['type'] ) ) {
1195 // Validate type definition
1196 if ( is_array( $definition['type'] ) ) {
1197 // Array of types (union types) - validate they're compatible with MCP clients
1198 $type_array = array_map( 'strval', $definition['type'] );
1199
1200 // Check for complex types that need additional schema details
1201 $complex_types = array_intersect( $type_array, [ 'object', 'array' ] );
1202 if ( !empty( $complex_types ) ) {
1203 if ( $this->logging ) {
1204 error_log(
1205 '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" property "' . $prop_name .
1206 '" has problematic union type with complex types: [' . implode( ', ', $type_array ) .
1207 ']. This breaks ChatGPT. Auto-fixing by removing type constraint.'
1208 );
1209 }
1210 // Auto-fix: Remove the type constraint to accept any value
1211 unset( $definition['type'] );
1212 // Keep description if present, or add one
1213 if ( !isset( $definition['description'] ) ) {
1214 $definition['description'] = 'Value can be of any type';
1215 }
1216 }
1217 else {
1218 $definition['type'] = $type_array;
1219 }
1220 }
1221 else {
1222 $definition['type'] = (string) $definition['type'];
1223 }
1224 }
1225
1226 $properties[ $prop_name ] = $definition;
1227 }
1228 }
1229
1230 $required = [];
1231 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
1232 foreach ( $schema['required'] as $field ) {
1233 $field_name = trim( (string) $field );
1234 if ( $field_name !== '' ) {
1235 $required[] = $field_name;
1236 }
1237 }
1238 $required = array_values( array_unique( $required ) );
1239 }
1240
1241 $normalized = [
1242 'type' => 'object',
1243 'properties' => empty( $properties ) ? new stdClass() : $properties,
1244 ];
1245
1246 if ( !empty( $required ) ) {
1247 $normalized['required'] = $required;
1248 }
1249
1250 if ( array_key_exists( 'additionalProperties', $schema ) ) {
1251 $normalized['additionalProperties'] = (bool) $schema['additionalProperties'];
1252 }
1253
1254 return $normalized;
1255 }
1256
1257 private function normalize_annotations( array $annotations, string $tool_name ): array {
1258 $allowed_keys = [ 'title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ];
1259 $normalized = [];
1260
1261 foreach ( $annotations as $key => $value ) {
1262 if ( !in_array( $key, $allowed_keys, true ) ) {
1263 continue;
1264 }
1265
1266 if ( in_array( $key, [ 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ], true ) ) {
1267 $normalized[ $key ] = (bool) $value;
1268 }
1269 elseif ( $key === 'title' ) {
1270 $normalized['title'] = wp_strip_all_tags( (string) $value );
1271 }
1272 }
1273
1274 if ( empty( $normalized ) && $this->logging && !empty( $annotations ) ) {
1275 error_log( '[AI Engine MCP] 🔎 Tool "' . $tool_name . '" included unsupported annotation keys.' );
1276 }
1277
1278 return $normalized;
1279 }
1280 #endregion
1281
1282 #region Tools Call (execute_tool)
1283 private function execute_tool( $tool, $args, $id ) {
1284 try {
1285 // Handle built-in tools first
1286 if ( $tool === 'mcp_ping' ) {
1287 if ( $this->logging ) {
1288 $this->log( '🛠️ Tool: mcp_ping' );
1289 }
1290 $ping_data = [
1291 'time' => gmdate( 'Y-m-d H:i:s' ),
1292 'name' => get_bloginfo( 'name' ),
1293 ];
1294 return [
1295 'jsonrpc' => '2.0',
1296 'id' => $id,
1297 'result' => [
1298 'content' => [
1299 [
1300 'type' => 'text',
1301 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
1302 ],
1303 ],
1304 'data' => $ping_data,
1305 ],
1306 ];
1307 }
1308
1309 // Let other modules handle their tools
1310 if ( $this->logging ) {
1311 // Log tool calls with more context
1312 $args_preview = '';
1313 if ( !empty( $args ) ) {
1314 // Show key args for common tools
1315 if ( isset( $args['ID'] ) ) {
1316 $args_preview = ' (ID: ' . $args['ID'] . ')';
1317 }
1318 elseif ( isset( $args['query'] ) ) {
1319 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
1320 }
1321 elseif ( isset( $args['message'] ) ) {
1322 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
1323 }
1324 }
1325 // Log to both error log and UI
1326 error_log( '[AI Engine MCP] 🛠️ ' . $tool . $args_preview );
1327 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
1328 }
1329 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
1330
1331 if ( $filtered !== null ) {
1332 // Check if it's already a full JSON-RPC response (backward compatibility)
1333 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
1334 return $filtered;
1335 }
1336
1337 // Otherwise, wrap the result in proper JSON-RPC format
1338 return [
1339 'jsonrpc' => '2.0',
1340 'id' => $id,
1341 'result' => $this->format_tool_result( $filtered ),
1342 ];
1343 }
1344
1345 throw new Exception( "Unknown tool: {$tool}" );
1346 }
1347 catch ( Exception $e ) {
1348 return $this->rpc_error( $id, -32603, $e->getMessage() );
1349 }
1350 }
1351 #endregion
1352
1353 #region Message Queue (per-message transient)
1354 private function transient_key( $sess, $id ) {
1355 return "{$this->queue_key}_{$sess}_{$id}";
1356 }
1357
1358 private function store_message( $sess, $payload ) {
1359 if ( !$sess ) {
1360 return;
1361 }
1362 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
1363 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
1364 $this->log( "queued #{$idKey}" );
1365 }
1366
1367 private function fetch_messages( $sess ) {
1368 global $wpdb;
1369 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
1370
1371 $rows = $wpdb->get_results(
1372 $wpdb->prepare(
1373 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
1374 $like
1375 ),
1376 ARRAY_A
1377 );
1378
1379 $msgs = [];
1380 foreach ( $rows as $r ) {
1381 $msgs[] = maybe_unserialize( $r['option_value'] );
1382 delete_option( $r['option_name'] );
1383 }
1384 usort( $msgs, fn ( $a, $b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
1385 if ( $msgs ) {
1386 $this->log( 'flush ' . count( $msgs ) . ' msg(s)' );
1387 }
1388 return $msgs;
1389 }
1390 #endregion
1391
1392 #region Resources (note)
1393 /*--------------------------------------------------*/
1394 /**
1395 * MCP also supports “resources” – static or dynamic data a client can
1396 * retrieve by URL (e.g. `mcp://resource/posts/123`).
1397 */
1398 #endregion
1399 }
1400