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