PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.0
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.0
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 6 days ago model-audit.php 1 week ago workspace-mock.html 1 week ago wpai-connectors.php 3 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
1319 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 /**
754 * Whether the Access Level setting narrows this request.
755 *
756 * It governs the shared bearer token, which is what its description has always
757 * said: one secret handed to a script, so the owner decides how far it reaches.
758 * An OAuth connection is the opposite case. It belongs to one person who signed
759 * in as themselves, and the authorize step already refuses anyone without
760 * manage_options, so narrowing them again by a global role meant an
761 * administrator on Claude Desktop silently lost every admin-level tool, with no
762 * reason given and no setting on screen to explain it (the selector only appears
763 * when a bearer token is configured).
764 *
765 * Both the listing and the execution gate call this. They used to decide it
766 * separately, which is how they drifted apart in the first place.
767 */
768 private function role_filter_applies(): bool {
769 return $this->auth_method !== 'oauth' && $this->mcp_role !== 'admin';
770 }
771
772 private function role_has_access( string $toolLevel ): bool {
773 if ( $this->mcp_role === 'admin' ) {
774 return true;
775 }
776 if ( $this->mcp_role === 'readwrite' ) {
777 return in_array( $toolLevel, [ 'read', 'write' ] );
778 }
779 if ( $this->mcp_role === 'readonly' ) {
780 return $toolLevel === 'read';
781 }
782 return false;
783 }
784 #endregion
785
786 #region Tools Definitions
787 private function get_tools_list() {
788 $base_tools = [
789 [
790 'name' => 'mcp_ping',
791 '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.',
792 'inputSchema' => [
793 'type' => 'object',
794 'properties' => (object) [],
795 'required' => []
796 ],
797 'annotations' => [
798 'readOnlyHint' => true,
799 'destructiveHint' => false,
800 'openWorldHint' => false,
801 ],
802 'accessLevel' => 'read',
803 ],
804 ];
805
806 if ( $this->logging ) {
807 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Starting with ' . count( $base_tools ) . ' base tools' );
808 }
809
810 $filtered_tools = apply_filters( 'mwai_mcp_tools', $base_tools );
811
812 if ( $this->logging ) {
813 error_log( '[AI Engine MCP] 🔧 get_tools_list() - After filters: ' . count( $filtered_tools ) . ' tools' );
814 }
815
816 // Build access level map for defense-in-depth checks in execute_tool()
817 foreach ( $filtered_tools as $tool ) {
818 if ( isset( $tool['name'] ) ) {
819 $this->tool_access_levels[ $tool['name'] ] = $tool['accessLevel'] ?? 'admin';
820 }
821 }
822
823 // Filter tools by access level based on the MCP role.
824 //
825 // This applies to the shared bearer token only, which is what the setting has
826 // always described: one secret handed to a script, so the site owner decides
827 // how far it reaches. An OAuth connection is the opposite case. It belongs to
828 // one person, they signed in as themselves, and authorize_token() already
829 // refuses anyone without manage_options, so filtering them again by a global
830 // role meant an administrator on Claude Desktop silently lost every
831 // admin-level tool with no visible reason and no setting on screen to explain
832 // it (the selector only appears when a bearer token is configured).
833 if ( $this->role_filter_applies() ) {
834 $filtered_tools = array_filter( $filtered_tools, function ( $tool ) {
835 $level = $tool['accessLevel'] ?? 'admin';
836 return $this->role_has_access( $level );
837 } );
838 }
839
840 $normalized_tools = [];
841 foreach ( $filtered_tools as $tool_index => $tool_definition ) {
842 $normalized = $this->normalize_tool_definition( $tool_definition, $tool_index );
843 if ( $normalized ) {
844 $normalized_tools[] = $normalized;
845 }
846 }
847
848 if ( $this->logging ) {
849 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Normalized tools: ' . count( $normalized_tools ) );
850 }
851
852 return $normalized_tools;
853 }
854 #endregion
855
856 #region Resources Definitions
857 private function get_resources_list() {
858 return [];
859 }
860 #endregion
861
862 #region Prompts Definitions
863 private function get_prompts_list() {
864 return [];
865 }
866 #endregion
867
868 #region Tool Normalization Helpers
869 private function normalize_tool_definition( $tool, $index ) {
870 // NOTE: tool-registration warnings below are always emitted (no $this->logging
871 // gate). Each fires only when a tool is silently auto-fixed or auto-skipped at
872 // registration — exactly the case where the author needs to know. They're rare
873 // in normal operation and the only reliable diagnostic when something is off.
874 if ( !is_array( $tool ) ) {
875 error_log( '[AI Engine MCP] ⚠️ Tool definition at index ' . $index . ' skipped (expected array).' );
876 return null;
877 }
878
879 $name = isset( $tool['name'] ) ? trim( (string) $tool['name'] ) : '';
880 if ( $name === '' ) {
881 error_log( '[AI Engine MCP] ⚠️ Tool skipped due to missing name at index ' . $index );
882 return null;
883 }
884
885 $normalized_schema = $this->normalize_input_schema( $tool['inputSchema'] ?? null, $name );
886 if ( !$normalized_schema ) {
887 error_log( '[AI Engine MCP] ⚠️ Tool "' . $name . '" skipped due to invalid input schema.' );
888 return null;
889 }
890
891 $normalized = [
892 'name' => $name,
893 'inputSchema' => $normalized_schema,
894 ];
895
896 if ( isset( $tool['description'] ) && $tool['description'] !== '' ) {
897 $normalized['description'] = wp_strip_all_tags( (string) $tool['description'] );
898 }
899
900 if ( isset( $tool['annotations'] ) && is_array( $tool['annotations'] ) ) {
901 $annotations = $this->normalize_annotations( $tool['annotations'], $name );
902 if ( !empty( $annotations ) ) {
903 $normalized['annotations'] = $annotations;
904 }
905 }
906
907 return $normalized;
908 }
909
910 private function normalize_input_schema( $schema, string $tool_name ) {
911 if ( !is_array( $schema ) ) {
912 return null;
913 }
914
915 $type = isset( $schema['type'] ) ? (string) $schema['type'] : 'object';
916 if ( $type !== 'object' ) {
917 error_log( '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" has unsupported schema type: ' . $type );
918 return null;
919 }
920
921 $properties = [];
922 if ( isset( $schema['properties'] ) && ( is_array( $schema['properties'] ) || is_object( $schema['properties'] ) ) ) {
923 foreach ( (array) $schema['properties'] as $prop_name => $definition ) {
924 if ( !is_array( $definition ) ) {
925 $definition = [];
926 }
927
928 if ( isset( $definition['type'] ) ) {
929 // Validate type definition
930 if ( is_array( $definition['type'] ) ) {
931 // Array of types (union types) - validate they're compatible with MCP clients
932 $type_array = array_map( 'strval', $definition['type'] );
933
934 // Check for complex types that need additional schema details
935 $complex_types = array_intersect( $type_array, [ 'object', 'array' ] );
936 if ( !empty( $complex_types ) ) {
937 error_log(
938 '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" property "' . $prop_name .
939 '" has problematic union type with complex types: [' . implode( ', ', $type_array ) .
940 ']. This breaks ChatGPT. Auto-fixing by removing type constraint.'
941 );
942 // Auto-fix: Remove the type constraint to accept any value
943 unset( $definition['type'] );
944 // Keep description if present, or add one
945 if ( !isset( $definition['description'] ) ) {
946 $definition['description'] = 'Value can be of any type';
947 }
948 }
949 else {
950 $definition['type'] = $type_array;
951 }
952 }
953 else {
954 $definition['type'] = (string) $definition['type'];
955 }
956 }
957
958 $properties[ $prop_name ] = $definition;
959 }
960 }
961
962 $required = [];
963 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
964 foreach ( $schema['required'] as $field ) {
965 $field_name = trim( (string) $field );
966 if ( $field_name !== '' ) {
967 $required[] = $field_name;
968 }
969 }
970 $required = array_values( array_unique( $required ) );
971 }
972
973 $normalized = [
974 'type' => 'object',
975 'properties' => empty( $properties ) ? new stdClass() : $properties,
976 ];
977
978 if ( !empty( $required ) ) {
979 $normalized['required'] = $required;
980 }
981
982 if ( array_key_exists( 'additionalProperties', $schema ) ) {
983 $normalized['additionalProperties'] = (bool) $schema['additionalProperties'];
984 }
985
986 return $normalized;
987 }
988
989 private function normalize_annotations( array $annotations, string $tool_name ): array {
990 $allowed_keys = [ 'title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ];
991 $normalized = [];
992
993 foreach ( $annotations as $key => $value ) {
994 if ( !in_array( $key, $allowed_keys, true ) ) {
995 continue;
996 }
997
998 if ( in_array( $key, [ 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ], true ) ) {
999 $normalized[ $key ] = (bool) $value;
1000 }
1001 elseif ( $key === 'title' ) {
1002 $normalized['title'] = wp_strip_all_tags( (string) $value );
1003 }
1004 }
1005
1006 if ( empty( $normalized ) && $this->logging && !empty( $annotations ) ) {
1007 error_log( '[AI Engine MCP] 🔎 Tool "' . $tool_name . '" included unsupported annotation keys.' );
1008 }
1009
1010 return $normalized;
1011 }
1012 #endregion
1013
1014 #region Tools Call (execute_tool)
1015
1016 // Armed while a tool runs, so the shutdown net below can answer for it.
1017 private static $currentToolCall = null;
1018 private static $shutdownNetRegistered = false;
1019 // Emergency memory reserve, released by the net so it can run even after an
1020 // out-of-memory fatal on hosts where ini_set is disabled.
1021 private static $memoryReserve = null;
1022
1023 /**
1024 * A tool callback that dies hard (out of memory, fatal error) would end the
1025 * request as a raw 500 with an empty body, and MCP clients then treat the
1026 * WHOLE server as unreachable (Anthropic aborts the conversation with
1027 * "Connection error while communicating with MCP server"). This shutdown
1028 * net answers with a valid JSON-RPC tool error instead, so only the tool
1029 * fails and the client/model can react to it.
1030 */
1031 private function arm_fatal_net( $tool, $id ) {
1032 self::$currentToolCall = [ 'tool' => $tool, 'id' => $id ];
1033 if ( self::$memoryReserve === null ) {
1034 self::$memoryReserve = str_repeat( 'x', 2 * 1024 * 1024 );
1035 }
1036 if ( self::$shutdownNetRegistered ) {
1037 return;
1038 }
1039 self::$shutdownNetRegistered = true;
1040 // WordPress's own fatal handler runs first (registered at bootstrap) and
1041 // exits after printing its "critical error" 500, which would keep our net
1042 // from ever running. WP_SANDBOX_SCRAPING is core's shutdown-time escape
1043 // hatch for "the request handles fatals itself" (the enabled filter is
1044 // only consulted at bootstrap, so it cannot be used here).
1045 if ( !defined( 'WP_SANDBOX_SCRAPING' ) ) {
1046 define( 'WP_SANDBOX_SCRAPING', true );
1047 }
1048 register_shutdown_function( function () {
1049 $ctx = self::$currentToolCall;
1050 if ( empty( $ctx ) ) {
1051 return;
1052 }
1053 $err = error_get_last();
1054 if ( !$err || !in_array( $err['type'], [ E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR ], true ) ) {
1055 return;
1056 }
1057 // An OOM can leave ZERO headroom, killing this emitter itself. Free the
1058 // reserve first (works everywhere), then lift the limit where allowed
1059 // (the request is over anyway).
1060 self::$memoryReserve = null;
1061 @ini_set( 'memory_limit', '-1' );
1062 // Discard any partial/buffered output so the JSON is the only body.
1063 while ( ob_get_level() > 0 ) {
1064 @ob_end_clean();
1065 }
1066 if ( !headers_sent() ) {
1067 http_response_code( 200 );
1068 header( 'Content-Type: application/json' );
1069 }
1070 $msg = 'The tool "' . $ctx['tool'] . '" crashed on this site (' .
1071 substr( $err['message'], 0, 300 ) . '). The other tools should still work.';
1072 echo '{"jsonrpc":"2.0","id":' . json_encode( $ctx['id'] ) .
1073 ',"result":{"content":[{"type":"text","text":' . json_encode( $msg ) . '}],"isError":true}}';
1074 } );
1075 }
1076
1077 private function execute_tool( $tool, $args, $id ) {
1078 $start = microtime( true );
1079 $response = null;
1080 $status = 'error';
1081 $error_msg = null;
1082 $this->arm_fatal_net( $tool, $id );
1083 try {
1084 // Ensure tool access levels are populated (each HTTP request starts fresh)
1085 if ( empty( $this->tool_access_levels ) ) {
1086 $this->get_tools_list();
1087 }
1088
1089 // Defense in depth: verify tool access even if it wasn't filtered from the listing
1090 $tool_level = $this->tool_access_levels[ $tool ] ?? 'admin';
1091 if ( $this->role_filter_applies() && !$this->role_has_access( $tool_level ) ) {
1092 $error_msg = "Access denied: tool '{$tool}' requires '{$tool_level}' access.";
1093 $response = $this->rpc_error( $id, -32600, $error_msg );
1094 return $response;
1095 }
1096
1097 // Handle built-in tools first
1098 if ( $tool === 'mcp_ping' ) {
1099 if ( $this->logging ) {
1100 $this->log( '🛠️ Tool: mcp_ping' );
1101 }
1102 $ping_data = [
1103 'time' => gmdate( 'Y-m-d H:i:s' ),
1104 'name' => get_bloginfo( 'name' ),
1105 ];
1106 $response = [
1107 'jsonrpc' => '2.0',
1108 'id' => $id,
1109 'result' => [
1110 'content' => [
1111 [
1112 'type' => 'text',
1113 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
1114 ],
1115 ],
1116 'data' => $ping_data,
1117 ],
1118 ];
1119 $status = 'success';
1120 return $response;
1121 }
1122
1123 // Let other modules handle their tools
1124 if ( $this->logging ) {
1125 // Log tool calls with more context
1126 $args_preview = '';
1127 if ( !empty( $args ) ) {
1128 // Show key args for common tools
1129 if ( isset( $args['ID'] ) ) {
1130 $args_preview = ' (ID: ' . $args['ID'] . ')';
1131 }
1132 elseif ( isset( $args['query'] ) ) {
1133 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
1134 }
1135 elseif ( isset( $args['message'] ) ) {
1136 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
1137 }
1138 }
1139 // Log to both error log and UI
1140 error_log( '[AI Engine MCP] 🛠️ ' . $tool . $args_preview );
1141 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
1142 }
1143 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
1144
1145 if ( $filtered !== null ) {
1146 // Check if it's already a full JSON-RPC response (backward compatibility)
1147 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
1148 $response = $filtered;
1149 $status = isset( $filtered['error'] ) ? 'error' : 'success';
1150 if ( $status === 'error' ) {
1151 $error_msg = $filtered['error']['message'] ?? null;
1152 }
1153 return $response;
1154 }
1155
1156 // Otherwise, wrap the result in proper JSON-RPC format
1157 $response = [
1158 'jsonrpc' => '2.0',
1159 'id' => $id,
1160 'result' => $this->format_tool_result( $filtered ),
1161 ];
1162 $status = 'success';
1163 return $response;
1164 }
1165
1166 throw new Exception( "Unknown tool: {$tool}" );
1167 }
1168 catch ( Throwable $e ) {
1169 // A failing tool is reported as a tool-level error (isError result),
1170 // NOT a JSON-RPC protocol error: clients treat protocol errors as a
1171 // broken server, while an isError result lets the model read the
1172 // message and adapt. Throwable also catches TypeError & friends.
1173 $error_msg = $e->getMessage();
1174 $response = [
1175 'jsonrpc' => '2.0',
1176 'id' => $id,
1177 'result' => [
1178 'content' => [
1179 [
1180 'type' => 'text',
1181 'text' => 'The tool "' . $tool . '" failed: ' . $error_msg,
1182 ],
1183 ],
1184 'isError' => true,
1185 ],
1186 ];
1187 return $response;
1188 }
1189 finally {
1190 self::$currentToolCall = null;
1191 $duration_ms = (int) round( ( microtime( true ) - $start ) * 1000 );
1192 // Fire the action even on access denials and errors so admins can see
1193 // attempted-but-blocked tool calls in MCP Logs.
1194 do_action( 'mwai_mcp_tool_called', [
1195 'tool' => $tool,
1196 'args' => $args,
1197 'result' => $response,
1198 'status' => $status,
1199 'error_msg' => $error_msg,
1200 'duration_ms' => $duration_ms,
1201 'client_id' => $this->auth_client_id,
1202 'client_name' => $this->auth_client_name,
1203 'auth_method' => $this->auth_method,
1204 'request_id' => $id,
1205 'user_id' => get_current_user_id(),
1206 ] );
1207 }
1208 }
1209 #endregion
1210
1211 #region Handle /upload (one-time file upload via token)
1212 public function handle_upload( WP_REST_Request $request ) {
1213 $token = $request->get_param( 'token' );
1214 if ( empty( $token ) ) {
1215 return new WP_REST_Response( [ 'success' => false, 'message' => 'Missing token.' ], 400 );
1216 }
1217
1218 $transient_key = 'mwai_mcp_upload_' . $token;
1219 $data = get_transient( $transient_key );
1220 if ( empty( $data ) ) {
1221 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid or expired upload token.' ], 403 );
1222 }
1223
1224 // Immediately delete the transient so the token can only be used once
1225 delete_transient( $transient_key );
1226
1227 $files = $request->get_file_params();
1228 if ( empty( $files['file'] ) ) {
1229 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided. Use: curl -X POST -F "file=@/path/to/file" "<url>"' ], 400 );
1230 }
1231
1232 $uploaded = $files['file'];
1233 if ( $uploaded['error'] !== UPLOAD_ERR_OK ) {
1234 return new WP_REST_Response( [ 'success' => false, 'message' => 'Upload error code: ' . $uploaded['error'] ], 400 );
1235 }
1236
1237 // Set admin context for media handling
1238 if ( !current_user_can( 'administrator' ) ) {
1239 wp_set_current_user( 1 );
1240 }
1241
1242 require_once ABSPATH . 'wp-admin/includes/file.php';
1243 require_once ABSPATH . 'wp-admin/includes/media.php';
1244 require_once ABSPATH . 'wp-admin/includes/image.php';
1245
1246 // Use the filename from the transient (sanitized at creation time)
1247 $file = [
1248 'name' => $data['filename'],
1249 'tmp_name' => $uploaded['tmp_name'],
1250 ];
1251
1252 $attachment_id = media_handle_sideload( $file, 0, $data['description'] );
1253 if ( is_wp_error( $attachment_id ) ) {
1254 return new WP_REST_Response( [ 'success' => false, 'message' => $attachment_id->get_error_message() ], 500 );
1255 }
1256
1257 if ( !empty( $data['title'] ) ) {
1258 wp_update_post( [ 'ID' => $attachment_id, 'post_title' => sanitize_text_field( $data['title'] ) ] );
1259 }
1260 if ( !empty( $data['alt'] ) ) {
1261 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $data['alt'] ) );
1262 }
1263
1264 return new WP_REST_Response( [
1265 'success' => true,
1266 'attachment_id' => $attachment_id,
1267 'url' => wp_get_attachment_url( $attachment_id ),
1268 ], 200 );
1269 }
1270 #endregion
1271
1272 #region Message Queue (per-message transient)
1273 private function transient_key( $sess, $id ) {
1274 return "{$this->queue_key}_{$sess}_{$id}";
1275 }
1276
1277 private function store_message( $sess, $payload ) {
1278 if ( !$sess ) {
1279 return;
1280 }
1281 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
1282 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
1283 $this->log( "queued #{$idKey}" );
1284 }
1285
1286 private function fetch_messages( $sess ) {
1287 global $wpdb;
1288 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
1289
1290 $rows = $wpdb->get_results(
1291 $wpdb->prepare(
1292 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
1293 $like
1294 ),
1295 ARRAY_A
1296 );
1297
1298 $msgs = [];
1299 foreach ( $rows as $r ) {
1300 $msgs[] = maybe_unserialize( $r['option_value'] );
1301 delete_option( $r['option_name'] );
1302 }
1303 usort( $msgs, fn ( $a, $b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
1304 if ( $msgs ) {
1305 $this->log( 'flush ' . count( $msgs ) . ' msg(s)' );
1306 }
1307 return $msgs;
1308 }
1309 #endregion
1310
1311 #region Resources (note)
1312 /*--------------------------------------------------*/
1313 /**
1314 * MCP also supports “resources” – static or dynamic data a client can
1315 * retrieve by URL (e.g. `mcp://resource/posts/123`).
1316 */
1317 #endregion
1318 }
1319