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