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