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