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