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