PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.1
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.1
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 2 months 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
1552 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 if ( !is_array( $tool ) ) {
1213 if ( $this->logging ) {
1214 error_log( '[AI Engine MCP] ⚠️ Tool definition at index ' . $index . ' skipped (expected array).' );
1215 }
1216 return null;
1217 }
1218
1219 $name = isset( $tool['name'] ) ? trim( (string) $tool['name'] ) : '';
1220 if ( $name === '' ) {
1221 if ( $this->logging ) {
1222 error_log( '[AI Engine MCP] ⚠️ Tool skipped due to missing name at index ' . $index );
1223 }
1224 return null;
1225 }
1226
1227 $normalized_schema = $this->normalize_input_schema( $tool['inputSchema'] ?? null, $name );
1228 if ( !$normalized_schema ) {
1229 if ( $this->logging ) {
1230 error_log( '[AI Engine MCP] ⚠️ Tool "' . $name . '" skipped due to invalid input schema.' );
1231 }
1232 return null;
1233 }
1234
1235 $normalized = [
1236 'name' => $name,
1237 'inputSchema' => $normalized_schema,
1238 ];
1239
1240 if ( isset( $tool['description'] ) && $tool['description'] !== '' ) {
1241 $normalized['description'] = wp_strip_all_tags( (string) $tool['description'] );
1242 }
1243
1244 if ( isset( $tool['annotations'] ) && is_array( $tool['annotations'] ) ) {
1245 $annotations = $this->normalize_annotations( $tool['annotations'], $name );
1246 if ( !empty( $annotations ) ) {
1247 $normalized['annotations'] = $annotations;
1248 }
1249 }
1250
1251 return $normalized;
1252 }
1253
1254 private function normalize_input_schema( $schema, string $tool_name ) {
1255 if ( !is_array( $schema ) ) {
1256 return null;
1257 }
1258
1259 $type = isset( $schema['type'] ) ? (string) $schema['type'] : 'object';
1260 if ( $type !== 'object' ) {
1261 if ( $this->logging ) {
1262 error_log( '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" has unsupported schema type: ' . $type );
1263 }
1264 return null;
1265 }
1266
1267 $properties = [];
1268 if ( isset( $schema['properties'] ) && ( is_array( $schema['properties'] ) || is_object( $schema['properties'] ) ) ) {
1269 foreach ( (array) $schema['properties'] as $prop_name => $definition ) {
1270 if ( !is_array( $definition ) ) {
1271 $definition = [];
1272 }
1273
1274 if ( isset( $definition['type'] ) ) {
1275 // Validate type definition
1276 if ( is_array( $definition['type'] ) ) {
1277 // Array of types (union types) - validate they're compatible with MCP clients
1278 $type_array = array_map( 'strval', $definition['type'] );
1279
1280 // Check for complex types that need additional schema details
1281 $complex_types = array_intersect( $type_array, [ 'object', 'array' ] );
1282 if ( !empty( $complex_types ) ) {
1283 if ( $this->logging ) {
1284 error_log(
1285 '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" property "' . $prop_name .
1286 '" has problematic union type with complex types: [' . implode( ', ', $type_array ) .
1287 ']. This breaks ChatGPT. Auto-fixing by removing type constraint.'
1288 );
1289 }
1290 // Auto-fix: Remove the type constraint to accept any value
1291 unset( $definition['type'] );
1292 // Keep description if present, or add one
1293 if ( !isset( $definition['description'] ) ) {
1294 $definition['description'] = 'Value can be of any type';
1295 }
1296 }
1297 else {
1298 $definition['type'] = $type_array;
1299 }
1300 }
1301 else {
1302 $definition['type'] = (string) $definition['type'];
1303 }
1304 }
1305
1306 $properties[ $prop_name ] = $definition;
1307 }
1308 }
1309
1310 $required = [];
1311 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
1312 foreach ( $schema['required'] as $field ) {
1313 $field_name = trim( (string) $field );
1314 if ( $field_name !== '' ) {
1315 $required[] = $field_name;
1316 }
1317 }
1318 $required = array_values( array_unique( $required ) );
1319 }
1320
1321 $normalized = [
1322 'type' => 'object',
1323 'properties' => empty( $properties ) ? new stdClass() : $properties,
1324 ];
1325
1326 if ( !empty( $required ) ) {
1327 $normalized['required'] = $required;
1328 }
1329
1330 if ( array_key_exists( 'additionalProperties', $schema ) ) {
1331 $normalized['additionalProperties'] = (bool) $schema['additionalProperties'];
1332 }
1333
1334 return $normalized;
1335 }
1336
1337 private function normalize_annotations( array $annotations, string $tool_name ): array {
1338 $allowed_keys = [ 'title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ];
1339 $normalized = [];
1340
1341 foreach ( $annotations as $key => $value ) {
1342 if ( !in_array( $key, $allowed_keys, true ) ) {
1343 continue;
1344 }
1345
1346 if ( in_array( $key, [ 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ], true ) ) {
1347 $normalized[ $key ] = (bool) $value;
1348 }
1349 elseif ( $key === 'title' ) {
1350 $normalized['title'] = wp_strip_all_tags( (string) $value );
1351 }
1352 }
1353
1354 if ( empty( $normalized ) && $this->logging && !empty( $annotations ) ) {
1355 error_log( '[AI Engine MCP] 🔎 Tool "' . $tool_name . '" included unsupported annotation keys.' );
1356 }
1357
1358 return $normalized;
1359 }
1360 #endregion
1361
1362 #region Tools Call (execute_tool)
1363 private function execute_tool( $tool, $args, $id ) {
1364 try {
1365 // Ensure tool access levels are populated (each HTTP request starts fresh)
1366 if ( empty( $this->tool_access_levels ) ) {
1367 $this->get_tools_list();
1368 }
1369
1370 // Defense in depth: verify tool access even if it wasn't filtered from the listing
1371 $tool_level = $this->tool_access_levels[ $tool ] ?? 'admin';
1372 if ( !$this->role_has_access( $tool_level ) ) {
1373 return $this->rpc_error( $id, -32600, "Access denied: tool '{$tool}' requires '{$tool_level}' access." );
1374 }
1375
1376 // Handle built-in tools first
1377 if ( $tool === 'mcp_ping' ) {
1378 if ( $this->logging ) {
1379 $this->log( '🛠️ Tool: mcp_ping' );
1380 }
1381 $ping_data = [
1382 'time' => gmdate( 'Y-m-d H:i:s' ),
1383 'name' => get_bloginfo( 'name' ),
1384 ];
1385 return [
1386 'jsonrpc' => '2.0',
1387 'id' => $id,
1388 'result' => [
1389 'content' => [
1390 [
1391 'type' => 'text',
1392 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
1393 ],
1394 ],
1395 'data' => $ping_data,
1396 ],
1397 ];
1398 }
1399
1400 // Let other modules handle their tools
1401 if ( $this->logging ) {
1402 // Log tool calls with more context
1403 $args_preview = '';
1404 if ( !empty( $args ) ) {
1405 // Show key args for common tools
1406 if ( isset( $args['ID'] ) ) {
1407 $args_preview = ' (ID: ' . $args['ID'] . ')';
1408 }
1409 elseif ( isset( $args['query'] ) ) {
1410 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
1411 }
1412 elseif ( isset( $args['message'] ) ) {
1413 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
1414 }
1415 }
1416 // Log to both error log and UI
1417 error_log( '[AI Engine MCP] 🛠️ ' . $tool . $args_preview );
1418 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
1419 }
1420 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
1421
1422 if ( $filtered !== null ) {
1423 // Check if it's already a full JSON-RPC response (backward compatibility)
1424 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
1425 return $filtered;
1426 }
1427
1428 // Otherwise, wrap the result in proper JSON-RPC format
1429 return [
1430 'jsonrpc' => '2.0',
1431 'id' => $id,
1432 'result' => $this->format_tool_result( $filtered ),
1433 ];
1434 }
1435
1436 throw new Exception( "Unknown tool: {$tool}" );
1437 }
1438 catch ( Exception $e ) {
1439 return $this->rpc_error( $id, -32603, $e->getMessage() );
1440 }
1441 }
1442 #endregion
1443
1444 #region Handle /upload (one-time file upload via token)
1445 public function handle_upload( WP_REST_Request $request ) {
1446 $token = $request->get_param( 'token' );
1447 if ( empty( $token ) ) {
1448 return new WP_REST_Response( [ 'success' => false, 'message' => 'Missing token.' ], 400 );
1449 }
1450
1451 $transient_key = 'mwai_mcp_upload_' . $token;
1452 $data = get_transient( $transient_key );
1453 if ( empty( $data ) ) {
1454 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid or expired upload token.' ], 403 );
1455 }
1456
1457 // Immediately delete the transient so the token can only be used once
1458 delete_transient( $transient_key );
1459
1460 $files = $request->get_file_params();
1461 if ( empty( $files['file'] ) ) {
1462 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided. Use: curl -X POST -F "file=@/path/to/file" "<url>"' ], 400 );
1463 }
1464
1465 $uploaded = $files['file'];
1466 if ( $uploaded['error'] !== UPLOAD_ERR_OK ) {
1467 return new WP_REST_Response( [ 'success' => false, 'message' => 'Upload error code: ' . $uploaded['error'] ], 400 );
1468 }
1469
1470 // Set admin context for media handling
1471 if ( !current_user_can( 'administrator' ) ) {
1472 wp_set_current_user( 1 );
1473 }
1474
1475 require_once ABSPATH . 'wp-admin/includes/file.php';
1476 require_once ABSPATH . 'wp-admin/includes/media.php';
1477 require_once ABSPATH . 'wp-admin/includes/image.php';
1478
1479 // Use the filename from the transient (sanitized at creation time)
1480 $file = [
1481 'name' => $data['filename'],
1482 'tmp_name' => $uploaded['tmp_name'],
1483 ];
1484
1485 $attachment_id = media_handle_sideload( $file, 0, $data['description'] );
1486 if ( is_wp_error( $attachment_id ) ) {
1487 return new WP_REST_Response( [ 'success' => false, 'message' => $attachment_id->get_error_message() ], 500 );
1488 }
1489
1490 if ( !empty( $data['title'] ) ) {
1491 wp_update_post( [ 'ID' => $attachment_id, 'post_title' => sanitize_text_field( $data['title'] ) ] );
1492 }
1493 if ( !empty( $data['alt'] ) ) {
1494 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $data['alt'] ) );
1495 }
1496
1497 return new WP_REST_Response( [
1498 'success' => true,
1499 'attachment_id' => $attachment_id,
1500 'url' => wp_get_attachment_url( $attachment_id ),
1501 ], 200 );
1502 }
1503 #endregion
1504
1505 #region Message Queue (per-message transient)
1506 private function transient_key( $sess, $id ) {
1507 return "{$this->queue_key}_{$sess}_{$id}";
1508 }
1509
1510 private function store_message( $sess, $payload ) {
1511 if ( !$sess ) {
1512 return;
1513 }
1514 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
1515 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
1516 $this->log( "queued #{$idKey}" );
1517 }
1518
1519 private function fetch_messages( $sess ) {
1520 global $wpdb;
1521 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
1522
1523 $rows = $wpdb->get_results(
1524 $wpdb->prepare(
1525 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
1526 $like
1527 ),
1528 ARRAY_A
1529 );
1530
1531 $msgs = [];
1532 foreach ( $rows as $r ) {
1533 $msgs[] = maybe_unserialize( $r['option_value'] );
1534 delete_option( $r['option_name'] );
1535 }
1536 usort( $msgs, fn ( $a, $b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
1537 if ( $msgs ) {
1538 $this->log( 'flush ' . count( $msgs ) . ' msg(s)' );
1539 }
1540 return $msgs;
1541 }
1542 #endregion
1543
1544 #region Resources (note)
1545 /*--------------------------------------------------*/
1546 /**
1547 * MCP also supports “resources” – static or dynamic data a client can
1548 * retrieve by URL (e.g. `mcp://resource/posts/123`).
1549 */
1550 #endregion
1551 }
1552