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