PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.1.7
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.1.7
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 8 months ago mcp-rest.php 1 year ago mcp.conf 1 year ago mcp.js 8 months ago mcp.md 8 months ago mcp.php 8 months ago oauth.php 9 months ago realtime.php 1 year ago
mcp.php
1163 lines
1 <?php
2
3 /**
4 * AI Engine MCP Server
5 *
6 * This class implements a Model Context Protocol (MCP) server for AI Engine.
7 *
8 * Current Implementation:
9 * - Works reliably with Claude App through the mcp.js relay
10 * - Works directly with Claude.ai and ChatGPT via SSE connections
11 * - Properly handles agent cancellation signals (notifications/cancelled) to free workers immediately
12 * - Uses 30-second timeout to prevent worker exhaustion from abandoned connections
13 * - Sends heartbeat signals to detect dead connections quickly
14 * - OAuth authentication flow is currently disabled due to security concerns
15 * (only static bearer tokens are supported)
16 *
17 * Connection Management:
18 * - Agents send notifications/cancelled when done, triggering immediate SSE closure
19 * - 30-second timeout ensures workers are freed even if agents forget to disconnect
20 * - Heartbeat comments (every 10s) help proxies and connection_aborted() detect dead sockets
21 * - Both the mcp.js relay and direct agent connections work reliably
22 */
23
24 class Meow_MWAI_Labs_MCP {
25 private $core = null;
26 private $namespace = 'mcp/v1';
27 private $server_version = '0.0.1';
28 private $protocol_version = '2025-06-18'; // Updated to match official MCP SDK
29 private $queue_key = 'mwai_mcp_msg';
30 private $session_id = null;
31 private $logging = false;
32 private $last_action_time = 0;
33 private $bearer_token = null;
34 // Placeholder for OAuth integration. Currently unused and kept for
35 // future implementation once the security model is revised.
36 private $oauth = null;
37
38 #region Initialize
39 public function __construct( $core ) {
40 $this->core = $core;
41
42 // Set logging based on option
43 $this->logging = $this->core->get_option( 'mcp_debug_mode', false );
44
45 // OAuth support is temporarily disabled due to security concerns.
46 // The previous implementation allowed unvalidated redirect URIs which
47 // introduced an open redirect vulnerability and the possibility to
48 // steal authorization codes. Until proper client registration with
49 // strict redirect URI validation is implemented, the OAuth feature is
50 // not loaded. See labs/oauth.php for the previous code and take care
51 // when re‑enabling it in the future.
52
53 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
54 }
55
56 public function is_logging_enabled() {
57 return $this->logging;
58 }
59
60 public function rest_api_init() {
61 // Load bearer token if not already loaded
62 if ( $this->bearer_token === null ) {
63 $this->bearer_token = $this->core->get_option( 'mcp_bearer_token' );
64 }
65
66 // Only add filter once
67 static $filter_added = false;
68 if ( !empty( $this->bearer_token ) && !$filter_added ) {
69 add_filter( 'mwai_allow_mcp', [ $this, 'auth_via_bearer_token' ], 10, 2 );
70 $filter_added = true;
71 }
72 register_rest_route( $this->namespace, '/sse', [
73 'methods' => [ 'GET', 'POST', 'HEAD' ], // Support HEAD for client endpoint checks
74 'callback' => [ $this, 'handle_sse' ],
75 'permission_callback' => function ( $request ) {
76 return $this->can_access_mcp( $request );
77 },
78 ] );
79
80 register_rest_route( $this->namespace, '/messages', [
81 'methods' => 'POST',
82 'callback' => [ $this, 'handle_message' ],
83 'permission_callback' => function ( $request ) {
84 return $this->can_access_mcp( $request );
85 },
86 ] );
87
88 // No-Auth URL endpoints (with token in path)
89 $noauth_enabled = $this->core->get_option( 'mcp_noauth_url' );
90 if ( $noauth_enabled && !empty( $this->bearer_token ) ) {
91 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/sse', [
92 'methods' => 'GET',
93 'callback' => [ $this, 'handle_sse' ],
94 'permission_callback' => function ( $request ) {
95 return $this->handle_noauth_access( $request );
96 },
97 'show_in_index' => false,
98 ] );
99
100 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/sse', [
101 'methods' => 'POST',
102 'callback' => [ $this, 'handle_sse' ],
103 'permission_callback' => function ( $request ) {
104 return $this->handle_noauth_access( $request );
105 },
106 'show_in_index' => false,
107 ] );
108
109 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/messages', [
110 'methods' => 'POST',
111 'callback' => [ $this, 'handle_message' ],
112 'permission_callback' => function ( $request ) {
113 return $this->handle_noauth_access( $request );
114 },
115 'show_in_index' => false,
116 ] );
117 }
118 }
119 #endregion
120
121 #region Auth (Bearer token)
122 /**
123 * SECURITY: MCP provides powerful WordPress management capabilities, so access must be strictly controlled.
124 *
125 * By default, only administrators can access MCP endpoints. This prevents lower-privileged users
126 * (subscribers, contributors, etc.) from executing dangerous operations like creating admin users,
127 * deleting content, or modifying settings.
128 *
129 * When a bearer token is configured, it overrides the default admin check, but access is DENIED
130 * unless a valid token is provided. This ensures MCP is secure even with default settings.
131 */
132 public function can_access_mcp( $request ) {
133 // Default to requiring administrator capability for security
134 $is_admin = current_user_can( 'administrator' );
135 return apply_filters( 'mwai_allow_mcp', $is_admin, $request );
136 }
137
138 public function auth_via_bearer_token( $allow, $request ) {
139 // Skip if already authenticated as admin
140 if ( $allow ) {
141 return $allow;
142 }
143
144 $hdr = $request->get_header( 'authorization' );
145
146 // If no authorization header but bearer token is configured, deny access
147 if ( !$hdr && !empty( $this->bearer_token ) ) {
148 if ( $this->logging ) {
149 error_log( '[AI Engine MCP] ❌ No authorization header provided.' );
150 }
151 return false;
152 }
153
154 // Check for Bearer token in header
155 if ( $hdr && preg_match( '/Bearer\s+(.+)/i', $hdr, $m ) ) {
156 $token = trim( $m[1] );
157 $auth_result = 'none';
158
159 // Check if it's an OAuth token
160 if ( $this->oauth ) {
161 $token_data = $this->oauth->validate_token( $token );
162 if ( $token_data ) {
163 // Set current user based on OAuth token
164 wp_set_current_user( $token_data['user_id'] );
165 $auth_result = 'oauth';
166 // Only log auth for SSE endpoint
167 if ( $this->logging && strpos( $request->get_route(), '/sse' ) !== false ) {
168 error_log( '[AI Engine MCP] 🔐 OAuth OK (user: ' . $token_data['user_id'] . ')' );
169 }
170 return true;
171 }
172 }
173
174 // Fall back to static bearer token if configured
175 if ( !empty( $this->bearer_token ) && hash_equals( $this->bearer_token, $token ) ) {
176 if ( $admin = $this->core->get_admin_user() ) {
177 wp_set_current_user( $admin->ID, $admin->user_login );
178 }
179 $auth_result = 'static';
180 // Only log auth for SSE endpoint
181 if ( $this->logging && strpos( $request->get_route(), '/sse' ) !== false ) {
182 error_log( '[AI Engine MCP] 🔐 Auth OK' );
183 }
184 return true;
185 }
186
187 if ( $this->logging && $auth_result === 'none' ) {
188 error_log( '[AI Engine MCP] ❌ Bearer token invalid.' );
189 }
190 // Explicitly deny access for invalid tokens
191 return false;
192 }
193
194 // ?token=xyz fallback (optional) - only for static bearer token
195 if ( !empty( $this->bearer_token ) ) {
196 $q = sanitize_text_field( $request->get_param( 'token' ) );
197 if ( $q && hash_equals( $this->bearer_token, $q ) ) {
198 if ( $admin = $this->core->get_admin_user() ) {
199 wp_set_current_user( $admin->ID, $admin->user_login );
200 }
201 return true;
202 }
203 }
204
205 // If bearer token is configured but no valid auth provided, deny access
206 if ( !empty( $this->bearer_token ) ) {
207 return false;
208 }
209
210 return $allow;
211 }
212
213 public function handle_noauth_access( $request ) {
214 // For no-auth URLs, the token is already verified by being in the URL path
215 // Double-check that the route actually contains the token
216 $route = $request->get_route();
217 if ( strpos( $route, '/' . $this->bearer_token . '/' ) === false ) {
218 if ( $this->logging ) {
219 error_log( '[AI Engine MCP] ❌ Invalid no-auth URL access attempt.' );
220 }
221 return false;
222 }
223
224 // Set the current user to admin since token is valid
225 if ( $admin = $this->core->get_admin_user() ) {
226 wp_set_current_user( $admin->ID, $admin->user_login );
227 }
228 return true;
229 }
230 #endregion
231
232 #region Helpers (log / JSON-RPC utils)
233 private function log( $msg ) {
234 // This method is for internal UI logs - keep it minimal
235 if ( $this->logging ) {
236 // Only log important messages to UI
237 if ( strpos( $msg, 'queued' ) === false && strpos( $msg, 'flush' ) === false ) {
238 Meow_MWAI_Logging::log( "[AI Engine MCP] {$msg}" );
239 }
240 }
241 }
242
243 /** Wrap a JSON-RPC error object */
244 private function rpc_error( $id, int $code, string $msg, $extra = null ): array {
245 $err = [ 'code' => $code, 'message' => $msg ];
246 if ( $extra !== null ) {
247 $err['data'] = $extra;
248 }
249 return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ];
250 }
251
252 /** Queue an error for SSE delivery */
253 private function queue_error( $sess, $id, int $code, string $msg, $extra = null ): void {
254 $this->store_message( $sess, $this->rpc_error( $id, $code, $msg, $extra ) );
255 }
256
257 /** Format tool result for MCP protocol */
258 private function format_tool_result( $result ): array {
259 // If result is a string, wrap it in the MCP content format
260 if ( is_string( $result ) ) {
261 return [
262 'content' => [
263 [
264 'type' => 'text',
265 'text' => $result,
266 ],
267 ],
268 ];
269 }
270
271 // If result has 'content' key, assume it's already properly formatted
272 if ( is_array( $result ) && isset( $result['content'] ) ) {
273 return $result;
274 }
275
276 // If result is an array without 'content' key, wrap it as JSON
277 if ( is_array( $result ) ) {
278 return [
279 'content' => [
280 [
281 'type' => 'text',
282 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT ),
283 ],
284 ],
285 'data' => $result,
286 ];
287 }
288
289 // For any other type, convert to string and wrap
290 return [
291 'content' => [
292 [
293 'type' => 'text',
294 'text' => (string) $result,
295 ],
296 ],
297 ];
298 }
299 #endregion
300
301 #region Handle direct JSON-RPC (for Claude's MCP client)
302 /**
303 * Claude's MCP client (via Anthropic API) sends JSON-RPC requests directly to the SSE endpoint
304 * as POST requests, rather than following the typical SSE flow:
305 * - Normal flow: GET /sse → establish SSE stream → POST /messages for JSON-RPC
306 * - Claude's flow: POST /sse with JSON-RPC body → expect immediate JSON response
307 *
308 * This method handles the direct JSON-RPC requests to maintain compatibility with Claude.
309 */
310 private function handle_direct_jsonrpc( WP_REST_Request $request, $data ) {
311 $id = $data['id'] ?? null;
312 $method = $data['method'] ?? null;
313
314 if ( json_last_error() !== JSON_ERROR_NONE ) {
315 $response = new WP_REST_Response( [
316 'jsonrpc' => '2.0',
317 'id' => null,
318 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
319 ], 200 );
320 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
321 $session_header = $request->get_header( 'mcp-session-id' );
322 if ( !empty( $session_header ) ) {
323 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
324 }
325 return $response;
326 }
327
328 if ( !is_array( $data ) || !$method ) {
329 $response = new WP_REST_Response( [
330 'jsonrpc' => '2.0',
331 'id' => $id,
332 'error' => [ 'code' => -32600, 'message' => 'Invalid Request' ]
333 ], 200 );
334 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
335 $session_header = $request->get_header( 'mcp-session-id' );
336 if ( !empty( $session_header ) ) {
337 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
338 }
339 return $response;
340 }
341
342 $session_header = $request->get_header( 'mcp-session-id' );
343 $session_id = '';
344 if ( !empty( $session_header ) ) {
345 $session_id = sanitize_text_field( $session_header );
346 }
347
348 if ( $method === 'initialize' || empty( $session_id ) ) {
349 $session_id = wp_generate_uuid4();
350 if ( $this->logging ) {
351 error_log( '[AI Engine MCP] 🆔 Direct session initialized: ' . $session_id );
352 }
353 }
354
355 try {
356 $reply = null;
357
358 switch ( $method ) {
359 case 'initialize':
360 // Check if client requests a specific protocol version
361 $params = $data['params'] ?? [];
362 $requested_version = $params['protocolVersion'] ?? null;
363 $client_info = $params['clientInfo'] ?? null;
364
365 if ( $this->logging && $client_info ) {
366 $client_name = $client_info['name'] ?? 'unknown';
367 $client_version = $client_info['version'] ?? 'unknown';
368 error_log( "[AI Engine MCP] Client: {$client_name} v{$client_version}" );
369 }
370
371 if ( $requested_version && $requested_version !== $this->protocol_version ) {
372 if ( $this->logging ) {
373 Meow_MWAI_Logging::warn( "[AI Engine MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
374 }
375 }
376
377 $reply = [
378 'jsonrpc' => '2.0',
379 'id' => $id,
380 'result' => [
381 'protocolVersion' => $this->protocol_version,
382 'serverInfo' => (object) [
383 'name' => get_bloginfo( 'name' ) . ' MCP',
384 'version' => $this->server_version,
385 ],
386 'capabilities' => (object) [
387 'tools' => new stdClass(), // Empty object, matching official SDK
388 ],
389 ],
390 ];
391 break;
392
393 case 'tools/list':
394 $tools = $this->get_tools_list();
395
396 // Debug logging for tools/list
397 if ( $this->logging ) {
398 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
399 error_log( '[AI Engine MCP Direct] 📋 tools/list requested by: ' . $user_agent );
400 error_log( '[AI Engine MCP Direct] 📊 Returning ' . count( $tools ) . ' tools' );
401 if ( count( $tools ) > 0 ) {
402 $tool_names = array_column( $tools, 'name' );
403 error_log( '[AI Engine MCP Direct] 🛠️ Tool names: ' . implode( ', ', $tool_names ) );
404 }
405 else {
406 error_log( '[AI Engine MCP Direct] ⚠️ WARNING: No tools returned!' );
407 }
408 }
409
410 $reply = [
411 'jsonrpc' => '2.0',
412 'id' => $id,
413 'result' => [ 'tools' => $tools ],
414 ];
415 break;
416
417 case 'tools/call':
418 $params = $data['params'] ?? [];
419 $tool = $params['name'] ?? '';
420 $arguments = $params['arguments'] ?? [];
421
422 if ( $this->logging ) {
423 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Tool: ' . $tool );
424 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Arguments: ' . wp_json_encode( $arguments ) );
425 }
426
427 try {
428 $reply = $this->execute_tool( $tool, $arguments, $id );
429 if ( $this->logging ) {
430 error_log( '[AI Engine MCP Direct] �
431 tools/call - Success for tool: ' . $tool );
432 }
433 }
434 catch ( Exception $e ) {
435 if ( $this->logging ) {
436 error_log( '[AI Engine MCP Direct] tools/call - Error: ' . $e->getMessage() );
437 }
438 throw $e;
439 }
440 break;
441
442 case 'notifications/initialized':
443 // This is a notification from the client indicating it has initialized
444 // No response needed for notifications
445 // Client initialized - no need to log
446 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
447 break;
448
449 default:
450 // Check if it's a notification (no id)
451 if ( $id === null && strpos( $method, 'notifications/' ) === 0 ) {
452 if ( $this->logging ) {
453 error_log( '[AI Engine MCP] 📨 Notification received: ' . $method );
454 }
455 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
456 }
457
458 $reply = [
459 'jsonrpc' => '2.0',
460 'id' => $id,
461 'error' => [ 'code' => -32601, 'message' => "Method not found: {$method}" ]
462 ];
463 }
464
465 // Ensure proper JSON-RPC response
466 $response = new WP_REST_Response( $reply, 200 );
467 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
468 return $this->attach_session_header( $response, $session_id );
469
470 }
471 catch ( Exception $e ) {
472 if ( $this->logging ) {
473 error_log( '[AI Engine MCP] ❌ Exception in handle_direct_jsonrpc: ' . $e->getMessage() );
474 }
475
476 $error_response = new WP_REST_Response( [
477 'jsonrpc' => '2.0',
478 'id' => $id,
479 'error' => [ 'code' => -32603, 'message' => 'Internal error', 'data' => $e->getMessage() ]
480 ], 200 );
481 $error_response->set_headers( [ 'Content-Type' => 'application/json' ] );
482 return $this->attach_session_header( $error_response, $session_id );
483 }
484 }
485 #endregion
486
487 #region Handle SSE (stream loop)
488 private function reply( string $event, $data = null, string $enc = 'json' ) {
489 // Handle special events
490 if ( $event === 'bye' ) {
491 echo "event: bye\ndata: \n\n";
492 if ( ob_get_level() ) {
493 ob_end_flush();
494 }
495 flush();
496 $this->last_action_time = time();
497 $this->log( 'Clean disconnection' );
498 return;
499 }
500
501 if ( $enc === 'json' && $data === null ) {
502 $this->log( "no data for {$event}" );
503 return;
504 }
505 echo "event: {$event}\n";
506 if ( $enc === 'json' ) {
507 $data = $data === null ? '{}' : wp_json_encode( $data, JSON_UNESCAPED_UNICODE );
508 }
509 echo 'data: ' . $data . "\n\n";
510
511 if ( ob_get_level() ) {
512 ob_end_flush();
513 }
514 flush();
515
516 $this->last_action_time = time();
517 // Only log endpoint announcements
518 if ( $event === 'endpoint' ) {
519 $this->log( 'SSE endpoint ready' );
520 }
521 }
522
523 private function generate_sse_id( $req ) {
524 $last = $req ? $req->get_header( 'last-event-id' ) : '';
525 return $last ?: str_replace( '-', '', wp_generate_uuid4() );
526 }
527
528 private function attach_session_header( WP_REST_Response $response, string $session_id ) {
529 if ( empty( $session_id ) ) {
530 return $response;
531 }
532
533 $response->header( 'Mcp-Session-Id', $session_id );
534
535 if ( $this->logging ) {
536 error_log( '[AI Engine MCP] 🪪 Response session header: ' . $session_id );
537 }
538
539 return $response;
540 }
541
542 public function handle_sse( WP_REST_Request $request ) {
543 // Handle HEAD request - just confirm endpoint exists
544 if ( $request->get_method() === 'HEAD' ) {
545 return new WP_REST_Response( null, 200, [
546 'Content-Type' => 'text/event-stream',
547 'Cache-Control' => 'no-cache',
548 ] );
549 }
550
551 $raw_body = $request->get_body();
552
553 // Handle POST request with JSON-RPC body (Direct MCP client behavior)
554 // Both Claude.ai and OpenAI/ChatGPT send JSON-RPC requests directly to the SSE endpoint
555 // instead of establishing an SSE connection first. This is non-standard but we need to support it.
556 // Expected flow: GET /sse (establish stream) → POST /messages (send JSON-RPC)
557 // Actual flow: POST /sse with JSON-RPC body → expects immediate JSON response
558 if ( $request->get_method() === 'POST' && !empty( $raw_body ) ) {
559 $data = json_decode( $raw_body, true );
560 if ( $data && isset( $data['method'] ) ) {
561 // Don't log here - it's already logged by log_requests()
562 // Process as a direct JSON-RPC request instead of starting SSE stream
563 return $this->handle_direct_jsonrpc( $request, $data );
564 }
565 }
566
567 @ini_set( 'zlib.output_compression', '0' );
568 @ini_set( 'output_buffering', '0' );
569 @ini_set( 'implicit_flush', '1' );
570 if ( function_exists( 'ob_implicit_flush' ) ) {
571 ob_implicit_flush( true );
572 }
573
574 header( 'Content-Type: text/event-stream' );
575 header( 'Cache-Control: no-cache' );
576 header( 'X-Accel-Buffering: no' );
577 header( 'Connection: keep-alive' );
578 while ( ob_get_level() ) {
579 ob_end_flush();
580 }
581
582 /* — greet client —*/
583 $this->session_id = $this->generate_sse_id( $request );
584 $this->last_action_time = time();
585 echo "id: {$this->session_id}\n\n";
586 flush();
587
588 $msg_uri = sprintf(
589 '%s/messages?session_id=%s',
590 rest_url( $this->namespace ),
591 $this->session_id
592 );
593 $this->reply( 'endpoint', $msg_uri, 'text' );
594 if ( $this->logging ) {
595 error_log( '[AI Engine MCP] �
596 SSE connected (' . substr( $this->session_id, 0, 8 ) . '...)' );
597 }
598
599 /* — main loop —*/
600 while ( true ) {
601 // Reduced timeout to free workers faster when agents disconnect
602 $max_time = $this->logging ? 30 : 60 * 3; // 30 seconds in debug, 3 minutes in production
603 $idle = ( time() - $this->last_action_time ) >= $max_time;
604 if ( connection_aborted() || $idle ) {
605 $this->reply( 'bye' );
606 if ( $this->logging ) {
607 error_log( '[AI Engine MCP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
608 }
609 break;
610 }
611
612 // Send heartbeat every 10 seconds to detect dead connections
613 $time_since_last = time() - $this->last_action_time;
614 if ( $time_since_last >= 10 && $time_since_last % 10 === 0 ) {
615 echo ": heartbeat\n\n";
616 if ( ob_get_level() ) {
617 ob_end_flush();
618 }
619 flush();
620 }
621
622 foreach ( $this->fetch_messages( $this->session_id ) as $p ) {
623 // Check for kill signal in the message queue
624 if ( isset( $p['method'] ) && $p['method'] === 'mwai/kill' ) {
625 if ( $this->logging ) {
626 error_log( '[AI Engine MCP] Kill signal - terminating' );
627 }
628 $this->reply( 'bye' );
629 exit;
630 }
631
632 // Don't log SSE responses - they clutter the logs
633 $this->reply( 'message', $p );
634 }
635
636 usleep( 200000 ); // 200 ms
637 }
638 exit;
639 }
640 #endregion
641
642 #region Handle /messages (JSON-RPC ingress)
643 public function handle_message( WP_REST_Request $request ) {
644 $sess = sanitize_text_field( $request->get_param( 'session_id' ) );
645 $raw = $request->get_body();
646 $dat = json_decode( $raw, true );
647
648 // Only log important methods in detail
649 if ( $this->logging && $dat && isset( $dat['method'] ) ) {
650 $method = $dat['method'];
651 // Skip logging for repetitive/less important notifications
652 if ( !in_array( $method, ['notifications/initialized', 'notifications/cancelled'] ) ) {
653 error_log( '[AI Engine MCP] ↓ ' . $method );
654 }
655 }
656
657 if ( json_last_error() !== JSON_ERROR_NONE ) {
658 $this->queue_error( $sess, null, -32700, 'Parse error: invalid JSON' );
659 return new WP_REST_Response( null, 204 );
660 }
661 if ( !is_array( $dat ) ) {
662 $this->queue_error( $sess, null, -32600, 'Invalid Request' );
663 return new WP_REST_Response( null, 204 );
664 }
665
666 $id = $dat['id'] ?? null;
667 $method = $dat['method'] ?? null;
668
669 /* — notifications —*/
670 if ( $method === 'initialized' ) {
671 return new WP_REST_Response( null, 204 );
672 }
673 if ( $method === 'notifications/cancelled' ) {
674 // Agent finished - queue kill signal to close SSE immediately
675 if ( $this->logging ) {
676 error_log( '[AI Engine MCP] Agent cancelled - closing SSE connection' );
677 }
678 $this->store_message( $sess, [
679 'jsonrpc' => '2.0',
680 'method' => 'mwai/kill'
681 ] );
682 return new WP_REST_Response( null, 204 );
683 }
684 if ( $method === 'mwai/kill' ) {
685 // Kill signal received - no need for verbose logging
686 // Queue the kill message for SSE to pick up before exiting
687 $this->store_message( $sess, [
688 'jsonrpc' => '2.0',
689 'method' => 'mwai/kill'
690 ] );
691 // Give it a moment to be stored
692 usleep( 100000 ); // 100ms
693 return new WP_REST_Response( null, 204 );
694 }
695
696 // It's a notification, no ID = no reply
697 if ( $id === null && $method !== null ) {
698 return new WP_REST_Response( null, 204 );
699 }
700
701 if ( !$method ) {
702 $this->queue_error( $sess, $id, -32600, 'Invalid Request: method missing' );
703 return new WP_REST_Response( null, 204 );
704 }
705
706 try {
707
708 $reply = null;
709
710 #region Methods switch
711 switch ( $method ) {
712
713 case 'initialize':
714 // Check if client requests a specific protocol version
715 $params = $dat['params'] ?? [];
716 $requested_version = $params['protocolVersion'] ?? null;
717 $client_info = $params['clientInfo'] ?? null;
718
719 if ( $this->logging && $client_info ) {
720 $client_name = $client_info['name'] ?? 'unknown';
721 $client_version = $client_info['version'] ?? 'unknown';
722 error_log( "[AI Engine MCP] Client: {$client_name} v{$client_version}" );
723 }
724
725 if ( $requested_version && $requested_version !== $this->protocol_version ) {
726 if ( $this->logging ) {
727 Meow_MWAI_Logging::warn( "[AI Engine MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
728 }
729 }
730
731 $reply = [
732 'jsonrpc' => '2.0',
733 'id' => $id,
734 'result' => [
735 'protocolVersion' => $this->protocol_version,
736 'serverInfo' => (object) [
737 'name' => get_bloginfo( 'name' ) . ' MCP',
738 'version' => $this->server_version,
739 ],
740 'capabilities' => (object) [
741 'tools' => new stdClass(), // Empty object, matching official SDK
742 ],
743 ],
744 ];
745 break;
746
747 case 'tools/list':
748 $tools = $this->get_tools_list();
749
750 // Debug logging for tools/list
751 if ( $this->logging ) {
752 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
753 error_log( '[AI Engine MCP] 📋 tools/list requested by: ' . $user_agent );
754 error_log( '[AI Engine MCP] 📊 Returning ' . count( $tools ) . ' tools' );
755 if ( count( $tools ) > 0 ) {
756 $tool_names = array_column( $tools, 'name' );
757 error_log( '[AI Engine MCP] 🛠️ Tool names: ' . implode( ', ', $tool_names ) );
758 }
759 else {
760 error_log( '[AI Engine MCP] ⚠️ WARNING: No tools returned!' );
761 }
762 }
763
764 $reply = [
765 'jsonrpc' => '2.0',
766 'id' => $id,
767 'result' => [ 'tools' => $tools ],
768 ];
769 break;
770
771 case 'resources/list':
772 $reply = [
773 'jsonrpc' => '2.0',
774 'id' => $id,
775 'result' => [ 'resources' => $this->get_resources_list() ],
776 ];
777 break;
778
779 case 'prompts/list':
780 $reply = [
781 'jsonrpc' => '2.0',
782 'id' => $id,
783 'result' => [ 'prompts' => $this->get_prompts_list() ],
784 ];
785 break;
786
787 case 'tools/call':
788 $params = $dat['params'] ?? [];
789 $tool = $params['name'] ?? '';
790 $arguments = $params['arguments'] ?? [];
791
792 if ( $this->logging ) {
793 error_log( '[AI Engine MCP SSE] 🔧 tools/call - Tool: ' . $tool );
794 error_log( '[AI Engine MCP SSE] 🔧 tools/call - Arguments: ' . wp_json_encode( $arguments ) );
795 }
796
797 try {
798 $reply = $this->execute_tool( $tool, $arguments, $id );
799 if ( $this->logging ) {
800 error_log( '[AI Engine MCP SSE] �
801 tools/call - Success for tool: ' . $tool );
802 }
803 }
804 catch ( Exception $e ) {
805 if ( $this->logging ) {
806 error_log( '[AI Engine MCP SSE] tools/call - Error: ' . $e->getMessage() );
807 }
808 throw $e;
809 }
810 break;
811
812 default:
813 $reply = $this->rpc_error( $id, -32601, "Method not found: {$method}" );
814 }
815 #endregion
816
817 if ( $reply ) {
818 // Don't log response queuing - it's too noisy
819 $this->store_message( $sess, $reply );
820 }
821
822 }
823 catch ( Exception $e ) {
824 $this->queue_error( $sess, $id, -32603, 'Internal error', $e->getMessage() );
825 }
826
827 return new WP_REST_Response( null, 204 );
828 }
829 #endregion
830
831 #region Tools Definitions
832 private function get_tools_list() {
833 $base_tools = [
834 [
835 'name' => 'mcp_ping',
836 '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.',
837 'inputSchema' => [
838 'type' => 'object',
839 'properties' => (object) [],
840 'required' => []
841 ],
842 'annotations' => [
843 'readOnlyHint' => true,
844 'destructiveHint' => false,
845 'openWorldHint' => false,
846 ],
847 ],
848 ];
849
850 if ( $this->logging ) {
851 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Starting with ' . count( $base_tools ) . ' base tools' );
852 }
853
854 $filtered_tools = apply_filters( 'mwai_mcp_tools', $base_tools );
855
856 if ( $this->logging ) {
857 error_log( '[AI Engine MCP] 🔧 get_tools_list() - After filters: ' . count( $filtered_tools ) . ' tools' );
858 }
859
860 $normalized_tools = [];
861 foreach ( $filtered_tools as $tool_index => $tool_definition ) {
862 $normalized = $this->normalize_tool_definition( $tool_definition, $tool_index );
863 if ( $normalized ) {
864 $normalized_tools[] = $normalized;
865 }
866 }
867
868 if ( $this->logging ) {
869 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Normalized tools: ' . count( $normalized_tools ) );
870 }
871
872 return $normalized_tools;
873 }
874 #endregion
875
876 #region Resources Definitions
877 private function get_resources_list() {
878 return [];
879 }
880 #endregion
881
882 #region Prompts Definitions
883 private function get_prompts_list() {
884 return [];
885 }
886 #endregion
887
888 #region Tool Normalization Helpers
889 private function normalize_tool_definition( $tool, $index ) {
890 if ( !is_array( $tool ) ) {
891 if ( $this->logging ) {
892 error_log( '[AI Engine MCP] ⚠️ Tool definition at index ' . $index . ' skipped (expected array).' );
893 }
894 return null;
895 }
896
897 $name = isset( $tool['name'] ) ? trim( (string) $tool['name'] ) : '';
898 if ( $name === '' ) {
899 if ( $this->logging ) {
900 error_log( '[AI Engine MCP] ⚠️ Tool skipped due to missing name at index ' . $index );
901 }
902 return null;
903 }
904
905 $normalized_schema = $this->normalize_input_schema( $tool['inputSchema'] ?? null, $name );
906 if ( !$normalized_schema ) {
907 if ( $this->logging ) {
908 error_log( '[AI Engine MCP] ⚠️ Tool "' . $name . '" skipped due to invalid input schema.' );
909 }
910 return null;
911 }
912
913 $normalized = [
914 'name' => $name,
915 'inputSchema' => $normalized_schema,
916 ];
917
918 if ( isset( $tool['description'] ) && $tool['description'] !== '' ) {
919 $normalized['description'] = wp_strip_all_tags( (string) $tool['description'] );
920 }
921
922 if ( isset( $tool['annotations'] ) && is_array( $tool['annotations'] ) ) {
923 $annotations = $this->normalize_annotations( $tool['annotations'], $name );
924 if ( !empty( $annotations ) ) {
925 $normalized['annotations'] = $annotations;
926 }
927 }
928
929 if ( isset( $tool['category'] ) ) {
930 $normalized['annotations'] = $normalized['annotations'] ?? [];
931 if ( empty( $normalized['annotations']['title'] ) ) {
932 $normalized['annotations']['title'] = wp_strip_all_tags( (string) $tool['category'] );
933 }
934 }
935
936 return $normalized;
937 }
938
939 private function normalize_input_schema( $schema, string $tool_name ) {
940 if ( !is_array( $schema ) ) {
941 return null;
942 }
943
944 $type = isset( $schema['type'] ) ? (string) $schema['type'] : 'object';
945 if ( $type !== 'object' ) {
946 if ( $this->logging ) {
947 error_log( '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" has unsupported schema type: ' . $type );
948 }
949 return null;
950 }
951
952 $properties = [];
953 if ( isset( $schema['properties'] ) && ( is_array( $schema['properties'] ) || is_object( $schema['properties'] ) ) ) {
954 foreach ( (array) $schema['properties'] as $prop_name => $definition ) {
955 if ( !is_array( $definition ) ) {
956 $definition = [];
957 }
958
959 if ( isset( $definition['type'] ) ) {
960 // Validate type definition
961 if ( is_array( $definition['type'] ) ) {
962 // Array of types (union types) - validate they're compatible with MCP clients
963 $type_array = array_map( 'strval', $definition['type'] );
964
965 // Check for complex types that need additional schema details
966 $complex_types = array_intersect( $type_array, [ 'object', 'array' ] );
967 if ( !empty( $complex_types ) ) {
968 if ( $this->logging ) {
969 error_log(
970 '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" property "' . $prop_name .
971 '" has problematic union type with complex types: [' . implode( ', ', $type_array ) .
972 ']. This breaks ChatGPT. Auto-fixing by removing type constraint.'
973 );
974 }
975 // Auto-fix: Remove the type constraint to accept any value
976 unset( $definition['type'] );
977 // Keep description if present, or add one
978 if ( !isset( $definition['description'] ) ) {
979 $definition['description'] = 'Value can be of any type';
980 }
981 } else {
982 $definition['type'] = $type_array;
983 }
984 } else {
985 $definition['type'] = (string) $definition['type'];
986 }
987 }
988
989 $properties[ $prop_name ] = $definition;
990 }
991 }
992
993 $required = [];
994 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
995 foreach ( $schema['required'] as $field ) {
996 $field_name = trim( (string) $field );
997 if ( $field_name !== '' ) {
998 $required[] = $field_name;
999 }
1000 }
1001 $required = array_values( array_unique( $required ) );
1002 }
1003
1004 $normalized = [
1005 'type' => 'object',
1006 'properties' => empty( $properties ) ? new stdClass() : $properties,
1007 ];
1008
1009 if ( !empty( $required ) ) {
1010 $normalized['required'] = $required;
1011 }
1012
1013 if ( array_key_exists( 'additionalProperties', $schema ) ) {
1014 $normalized['additionalProperties'] = (bool) $schema['additionalProperties'];
1015 }
1016
1017 return $normalized;
1018 }
1019
1020 private function normalize_annotations( array $annotations, string $tool_name ): array {
1021 $allowed_keys = [ 'title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ];
1022 $normalized = [];
1023
1024 foreach ( $annotations as $key => $value ) {
1025 if ( !in_array( $key, $allowed_keys, true ) ) {
1026 continue;
1027 }
1028
1029 if ( in_array( $key, [ 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ], true ) ) {
1030 $normalized[ $key ] = (bool) $value;
1031 }
1032 elseif ( $key === 'title' ) {
1033 $normalized['title'] = wp_strip_all_tags( (string) $value );
1034 }
1035 }
1036
1037 if ( empty( $normalized ) && $this->logging && !empty( $annotations ) ) {
1038 error_log( '[AI Engine MCP] 🔎 Tool "' . $tool_name . '" included unsupported annotation keys.' );
1039 }
1040
1041 return $normalized;
1042 }
1043 #endregion
1044
1045 #region Tools Call (execute_tool)
1046 private function execute_tool( $tool, $args, $id ) {
1047 try {
1048 // Handle built-in tools first
1049 if ( $tool === 'mcp_ping' ) {
1050 if ( $this->logging ) {
1051 $this->log( '🛠️ Tool: mcp_ping' );
1052 }
1053 $ping_data = [
1054 'time' => gmdate( 'Y-m-d H:i:s' ),
1055 'name' => get_bloginfo( 'name' ),
1056 ];
1057 return [
1058 'jsonrpc' => '2.0',
1059 'id' => $id,
1060 'result' => [
1061 'content' => [
1062 [
1063 'type' => 'text',
1064 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
1065 ],
1066 ],
1067 'data' => $ping_data,
1068 ],
1069 ];
1070 }
1071
1072 // Let other modules handle their tools
1073 if ( $this->logging ) {
1074 // Log tool calls with more context
1075 $args_preview = '';
1076 if ( !empty( $args ) ) {
1077 // Show key args for common tools
1078 if ( isset( $args['ID'] ) ) {
1079 $args_preview = ' (ID: ' . $args['ID'] . ')';
1080 }
1081 elseif ( isset( $args['query'] ) ) {
1082 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
1083 }
1084 elseif ( isset( $args['message'] ) ) {
1085 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
1086 }
1087 }
1088 // Log to both error log and UI
1089 error_log( '[AI Engine MCP] 🛠️ ' . $tool . $args_preview );
1090 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
1091 }
1092 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
1093
1094 if ( $filtered !== null ) {
1095 // Check if it's already a full JSON-RPC response (backward compatibility)
1096 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
1097 return $filtered;
1098 }
1099
1100 // Otherwise, wrap the result in proper JSON-RPC format
1101 return [
1102 'jsonrpc' => '2.0',
1103 'id' => $id,
1104 'result' => $this->format_tool_result( $filtered ),
1105 ];
1106 }
1107
1108 throw new Exception( "Unknown tool: {$tool}" );
1109 }
1110 catch ( Exception $e ) {
1111 return $this->rpc_error( $id, -32603, $e->getMessage() );
1112 }
1113 }
1114 #endregion
1115
1116 #region Message Queue (per-message transient)
1117 private function transient_key( $sess, $id ) {
1118 return "{$this->queue_key}_{$sess}_{$id}";
1119 }
1120
1121 private function store_message( $sess, $payload ) {
1122 if ( !$sess ) {
1123 return;
1124 }
1125 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
1126 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
1127 $this->log( "queued #{$idKey}" );
1128 }
1129
1130 private function fetch_messages( $sess ) {
1131 global $wpdb;
1132 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
1133
1134 $rows = $wpdb->get_results(
1135 $wpdb->prepare(
1136 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
1137 $like
1138 ),
1139 ARRAY_A
1140 );
1141
1142 $msgs = [];
1143 foreach ( $rows as $r ) {
1144 $msgs[] = maybe_unserialize( $r['option_value'] );
1145 delete_option( $r['option_name'] );
1146 }
1147 usort( $msgs, fn ( $a, $b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
1148 if ( $msgs ) {
1149 $this->log( 'flush ' . count( $msgs ) . ' msg(s)' );
1150 }
1151 return $msgs;
1152 }
1153 #endregion
1154
1155 #region Resources (note)
1156 /*--------------------------------------------------*/
1157 /**
1158 * MCP also supports “resources” – static or dynamic data a client can
1159 * retrieve by URL (e.g. `mcp://resource/posts/123`).
1160 */
1161 #endregion
1162 }
1163