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