PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 2.8.4
AI Engine – The Chatbot, AI Framework & MCP for WordPress v2.8.4
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.conf 1 year ago mcp.js 1 year ago mcp.md 1 year ago mcp.php 1 year ago mcp_core.php 1 year ago mcp_rest.php 1 year ago oauth.php 1 year ago realtime.php 1 year ago
mcp.php
966 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 * - The mcp.js relay handles proper disconnection, mwai/kill signals, and other AI Engine-specific features
11 * - Supports OAuth authentication flow alongside static bearer tokens
12 * - Works with OpenAI/ChatGPT, but OpenAI limits MCP to only 'search' and 'fetch' tools for their Deep Research feature
13 * (these tools are provided by AI Engine's Tuned Core module and search through WordPress posts/pages)
14 *
15 * Direct Connection Challenges:
16 * The goal is to support direct connections from Claude.ai and OpenAI to this MCP server without
17 * requiring the mcp.js relay. However, this is challenging due to:
18 * - PHP's blocking nature causing threads to freeze during long-running SSE connections
19 * - Difficulty in properly handling connection termination and cleanup
20 * - Protocol version differences between clients (e.g., Claude.ai uses 2024-11-05)
21 * - Multiple rapid reconnection attempts from AI services overwhelming the PHP server
22 *
23 * OpenAI Limitations:
24 * - OpenAI's MCP implementation is limited to Deep Research functionality only
25 * - Only 'search' and 'fetch' tools are supported (no other WordPress management tools)
26 * - This significantly limits the MCP capabilities compared to Claude's full implementation
27 *
28 * The mcp.js relay remains the recommended approach for production use until these
29 * direct connection issues are resolved.
30 */
31
32 class Meow_MWAI_Labs_MCP {
33 private $core = null;
34 private $namespace = 'mcp/v1';
35 private $server_version = '0.0.1';
36 private $protocol_version = '2024-11-05';
37 private $queue_key = 'mwai_mcp_msg';
38 private $session_id = null;
39 private $logging = false;
40 private $last_action_time = 0;
41 private $bearer_token = null;
42 private $oauth = null;
43
44 #region Initialize
45 public function __construct( $core ) {
46 $this->core = $core;
47
48 // Set logging based on option
49 $this->logging = $this->core->get_option( 'mcp_debug_mode', false );
50
51 // Initialize OAuth if enabled
52 if ( $this->core->get_option( 'module_mcp' ) ) {
53 require_once( __DIR__ . '/oauth.php' );
54 $this->oauth = new Meow_MWAI_Labs_OAuth( $core, $this->logging );
55
56 // Handle OAuth callback
57 add_action( 'init', [ $this->oauth, 'handle_oauth_callback' ] );
58
59 // Cleanup expired tokens periodically
60 add_action( 'mwai_cleanup_oauth', [ $this->oauth, 'cleanup_expired' ] );
61 if ( ! wp_next_scheduled( 'mwai_cleanup_oauth' ) ) {
62 wp_schedule_event( time(), 'hourly', 'mwai_cleanup_oauth' );
63 }
64 }
65
66 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
67
68 // Log MCP-related requests when logging is enabled
69 if ( $this->logging ) {
70 add_action( 'init', [ $this, 'log_requests' ], 1 );
71 }
72 }
73
74 public function log_requests() {
75 if ( ! $this->logging || empty( $_SERVER['REQUEST_METHOD'] ) || empty( $_SERVER['REQUEST_URI'] ) ) {
76 return;
77 }
78
79 $uri = $_SERVER['REQUEST_URI'];
80
81 // Only log MCP-related requests
82 if ( strpos( $uri, '/mcp/' ) === false && strpos( $uri, '/mwai/' ) === false && strpos( $uri, '/.well-known/oauth' ) === false ) {
83 return;
84 }
85
86 // Skip patterns we don't want to log
87 $skip_patterns = [
88 '/wp-admin/',
89 '/wp-cron.php',
90 '/favicon.ico',
91 ];
92
93 foreach ( $skip_patterns as $pattern ) {
94 if ( strpos( $uri, $pattern ) !== false ) {
95 return;
96 }
97 }
98
99 // Get user agent (shortened)
100 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'Unknown';
101 if ( strpos( $user_agent, 'Mozilla' ) !== false ) {
102 $user_agent = 'Mozilla/5.0';
103 } elseif ( strpos( $user_agent, 'python-httpx' ) !== false ) {
104 $user_agent = 'python-httpx/0.27.0';
105 } elseif ( strpos( $user_agent, 'node' ) !== false ) {
106 $user_agent = 'node';
107 }
108
109 // Get IP address (considering proxies)
110 $ip = 'Unknown';
111 if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
112 $ip = $_SERVER['HTTP_CLIENT_IP'];
113 } elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
114 // X-Forwarded-For can contain multiple IPs, get the first one
115 $ips = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] );
116 $ip = trim( $ips[0] );
117 } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
118 $ip = $_SERVER['REMOTE_ADDR'];
119 }
120
121 // Simplify URI for readability
122 $uri_parts = parse_url( $_SERVER['REQUEST_URI'] );
123 $path = $uri_parts['path'] ?? $_SERVER['REQUEST_URI'];
124 $simple_path = str_replace( '/wp-json', '', $path );
125
126 // Only show session ID for /messages requests
127 if ( strpos( $path, '/messages' ) !== false && !empty( $uri_parts['query'] ) ) {
128 parse_str( $uri_parts['query'], $query_params );
129 if ( isset( $query_params['session_id'] ) ) {
130 $simple_path .= ' (' . substr( $query_params['session_id'], 0, 8 ) . '...)';
131 }
132 }
133
134 // Uncomment the line below to see ALL HTTP requests in logs (useful when debugging Claude, ChatGPT, etc)
135 // This shows every request made by AI services to understand their connection patterns
136 // error_log( '[MCP] ' . $_SERVER['REQUEST_METHOD'] . ' ' . $simple_path );
137 }
138
139 public function is_logging_enabled() {
140 return $this->logging;
141 }
142
143 public function rest_api_init() {
144 // Load bearer token if not already loaded
145 if ( $this->bearer_token === null ) {
146 $this->bearer_token = $this->core->get_option( 'mcp_bearer_token' );
147 }
148
149 // Only add filter once
150 static $filter_added = false;
151 if ( !empty( $this->bearer_token ) && !$filter_added ) {
152 add_filter( 'mwai_allow_mcp', [ $this, 'auth_via_bearer_token' ], 10, 2 );
153 $filter_added = true;
154 }
155 register_rest_route( $this->namespace, '/sse', [
156 'methods' => 'GET',
157 'callback' => [ $this, 'handle_sse' ],
158 'permission_callback' => function( $request ) {
159 return $this->can_access_mcp( $request );
160 },
161 ] );
162
163 register_rest_route( $this->namespace, '/sse', [
164 'methods' => 'POST',
165 'callback' => [ $this, 'handle_sse' ],
166 'permission_callback' => function( $request ) {
167 return $this->can_access_mcp( $request );
168 },
169 ] );
170
171 register_rest_route( $this->namespace, '/messages', [
172 'methods' => 'POST',
173 'callback' => [ $this, 'handle_message' ],
174 'permission_callback' => function( $request ) {
175 return $this->can_access_mcp( $request );
176 },
177 ] );
178
179 // No-Auth URL endpoints (with token in path)
180 $noauth_enabled = $this->core->get_option( 'mcp_noauth_url' );
181 if ( $noauth_enabled && !empty( $this->bearer_token ) ) {
182 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/sse', [
183 'methods' => 'GET',
184 'callback' => [ $this, 'handle_sse' ],
185 'permission_callback' => function( $request ) {
186 return $this->handle_noauth_access( $request );
187 },
188 ] );
189
190 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/sse', [
191 'methods' => 'POST',
192 'callback' => [ $this, 'handle_sse' ],
193 'permission_callback' => function( $request ) {
194 return $this->handle_noauth_access( $request );
195 },
196 ] );
197
198 register_rest_route( $this->namespace, '/' . $this->bearer_token . '/messages', [
199 'methods' => 'POST',
200 'callback' => [ $this, 'handle_message' ],
201 'permission_callback' => function( $request ) {
202 return $this->handle_noauth_access( $request );
203 },
204 ] );
205 }
206 }
207 #endregion
208
209 #region Auth (Bearer token)
210 /**
211 * SECURITY: MCP provides powerful WordPress management capabilities, so access must be strictly controlled.
212 *
213 * By default, only administrators can access MCP endpoints. This prevents lower-privileged users
214 * (subscribers, contributors, etc.) from executing dangerous operations like creating admin users,
215 * deleting content, or modifying settings.
216 *
217 * When a bearer token is configured, it overrides the default admin check, but access is DENIED
218 * unless a valid token is provided. This ensures MCP is secure even with default settings.
219 */
220 function can_access_mcp( $request ) {
221 // Default to requiring administrator capability for security
222 $is_admin = current_user_can( 'administrator' );
223 return apply_filters( 'mwai_allow_mcp', $is_admin, $request );
224 }
225
226 public function auth_via_bearer_token( $allow, $request ) {
227 // Skip if already authenticated as admin
228 if ( $allow ) {
229 return $allow;
230 }
231
232 $hdr = $request->get_header( 'authorization' );
233
234 // If no authorization header but bearer token is configured, deny access
235 if ( !$hdr && !empty( $this->bearer_token ) ) {
236 if ( $this->logging ) {
237 error_log( '[MCP] ❌ No authorization header provided.' );
238 }
239 return false;
240 }
241
242 // Check for Bearer token in header
243 if ( $hdr && preg_match( '/Bearer\s+(.+)/i', $hdr, $m ) ) {
244 $token = trim( $m[1] );
245 $auth_result = 'none';
246
247 // Check if it's an OAuth token
248 if ( $this->oauth ) {
249 $token_data = $this->oauth->validate_token( $token );
250 if ( $token_data ) {
251 // Set current user based on OAuth token
252 wp_set_current_user( $token_data['user_id'] );
253 $auth_result = 'oauth';
254 // Only log auth for SSE endpoint
255 if ( $this->logging && strpos( $request->get_route(), '/sse' ) !== false ) {
256 error_log( '[MCP] 🔐 OAuth OK (user: ' . $token_data['user_id'] . ')' );
257 }
258 return true;
259 }
260 }
261
262 // Fall back to static bearer token if configured
263 if ( !empty( $this->bearer_token ) && hash_equals( $this->bearer_token, $token ) ) {
264 if ( $admin = $this->core->get_admin_user() ) {
265 wp_set_current_user( $admin->ID, $admin->user_login );
266 }
267 $auth_result = 'static';
268 // Only log auth for SSE endpoint
269 if ( $this->logging && strpos( $request->get_route(), '/sse' ) !== false ) {
270 error_log( '[MCP] 🔐 Auth OK' );
271 }
272 return true;
273 }
274
275 if ( $this->logging && $auth_result === 'none' ) {
276 error_log( '[MCP] ❌ Bearer token invalid.' );
277 }
278 // Explicitly deny access for invalid tokens
279 return false;
280 }
281
282 // ?token=xyz fallback (optional) - only for static bearer token
283 if ( !empty( $this->bearer_token ) ) {
284 $q = sanitize_text_field( $request->get_param( 'token' ) );
285 if ( $q && hash_equals( $this->bearer_token, $q ) ) {
286 if ( $admin = $this->core->get_admin_user() ) {
287 wp_set_current_user( $admin->ID, $admin->user_login );
288 }
289 return true;
290 }
291 }
292
293 // If bearer token is configured but no valid auth provided, deny access
294 if ( !empty( $this->bearer_token ) ) {
295 return false;
296 }
297
298 return $allow;
299 }
300
301 public function handle_noauth_access( $request ) {
302 // For no-auth URLs, the token is already verified by being in the URL path
303 // Double-check that the route actually contains the token
304 $route = $request->get_route();
305 if ( strpos( $route, '/' . $this->bearer_token . '/' ) === false ) {
306 if ( $this->logging ) {
307 error_log( '[MCP] ❌ Invalid no-auth URL access attempt.' );
308 }
309 return false;
310 }
311
312 // Set the current user to admin since token is valid
313 if ( $admin = $this->core->get_admin_user() ) {
314 wp_set_current_user( $admin->ID, $admin->user_login );
315 }
316 return true;
317 }
318 #endregion
319
320 #region Helpers (log / JSON-RPC utils)
321 private function log( $msg ) {
322 // This method is for internal UI logs - keep it minimal
323 if ( $this->logging ) {
324 // Only log important messages to UI
325 if ( strpos( $msg, 'queued' ) === false && strpos( $msg, 'flush' ) === false ) {
326 Meow_MWAI_Logging::log( "[MCP] {$msg}" );
327 }
328 }
329 }
330
331 /** Wrap a JSON-RPC error object */
332 private function rpc_error( $id, int $code, string $msg, $extra = null ): array {
333 $err = [ 'code' => $code, 'message' => $msg ];
334 if ( $extra !== null ) $err['data'] = $extra;
335 return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ];
336 }
337
338 /** Queue an error for SSE delivery */
339 private function queue_error( $sess, $id, int $code, string $msg, $extra = null ): void {
340 $this->store_message( $sess, $this->rpc_error( $id, $code, $msg, $extra ) );
341 }
342
343 /** Format tool result for MCP protocol */
344 private function format_tool_result( $result ) : array {
345 // If result is a string, wrap it in the MCP content format
346 if ( is_string( $result ) ) {
347 return [
348 'content' => [
349 [
350 'type' => 'text',
351 'text' => $result,
352 ],
353 ],
354 ];
355 }
356
357 // If result has 'content' key, assume it's already properly formatted
358 if ( is_array( $result ) && isset( $result['content'] ) ) {
359 return $result;
360 }
361
362 // If result is an array without 'content' key, wrap it as JSON
363 if ( is_array( $result ) ) {
364 return [
365 'content' => [
366 [
367 'type' => 'text',
368 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT ),
369 ],
370 ],
371 'data' => $result,
372 ];
373 }
374
375 // For any other type, convert to string and wrap
376 return [
377 'content' => [
378 [
379 'type' => 'text',
380 'text' => (string) $result,
381 ],
382 ],
383 ];
384 }
385 #endregion
386
387 #region Handle direct JSON-RPC (for Claude's MCP client)
388 /**
389 * Claude's MCP client (via Anthropic API) sends JSON-RPC requests directly to the SSE endpoint
390 * as POST requests, rather than following the typical SSE flow:
391 * - Normal flow: GET /sse → establish SSE stream → POST /messages for JSON-RPC
392 * - Claude's flow: POST /sse with JSON-RPC body → expect immediate JSON response
393 *
394 * This method handles the direct JSON-RPC requests to maintain compatibility with Claude.
395 */
396 private function handle_direct_jsonrpc( WP_REST_Request $request, $data ) {
397 $id = $data['id'] ?? null;
398 $method = $data['method'] ?? null;
399
400 if ( json_last_error() !== JSON_ERROR_NONE ) {
401 return new WP_REST_Response( [
402 'jsonrpc' => '2.0',
403 'id' => null,
404 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
405 ], 200 );
406 }
407
408 if ( ! is_array( $data ) || !$method ) {
409 return new WP_REST_Response( [
410 'jsonrpc' => '2.0',
411 'id' => $id,
412 'error' => [ 'code' => -32600, 'message' => 'Invalid Request' ]
413 ], 200 );
414 }
415
416 try {
417 $reply = null;
418
419 switch ( $method ) {
420 case 'initialize':
421 // Check if client requests a specific protocol version
422 $params = $data['params'] ?? [];
423 $requested_version = $params['protocolVersion'] ?? null;
424 $client_info = $params['clientInfo'] ?? null;
425
426 if ( $this->logging && $client_info ) {
427 $client_name = $client_info['name'] ?? 'unknown';
428 $client_version = $client_info['version'] ?? 'unknown';
429 error_log( "[MCP] Client: {$client_name} v{$client_version}" );
430 }
431
432 if ( $requested_version && $requested_version !== $this->protocol_version ) {
433 if ( $this->logging ) {
434 Meow_MWAI_Logging::warn( "[MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
435 }
436 }
437
438 $reply = [
439 'jsonrpc' => '2.0',
440 'id' => $id,
441 'result' => [
442 'protocolVersion' => $this->protocol_version,
443 'serverInfo' => (object)[
444 'name' => get_bloginfo( 'name' ) . ' MCP',
445 'version' => $this->server_version,
446 ],
447 'capabilities' => [
448 'tools' => [ 'listChanged' => true ],
449 'prompts' => [ 'subscribe' => false, 'listChanged' => false ],
450 'resources' => [ 'subscribe' => false, 'listChanged' => false ],
451 ],
452 ],
453 ];
454 break;
455
456 case 'tools/list':
457 // Don't log every tools/list request as it's too repetitive
458
459 // Check if this is OpenAI by checking the User-Agent
460 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
461 $is_openai = strpos( $user_agent, 'openai-mcp' ) !== false;
462
463 if ( $is_openai && $this->logging ) {
464 error_log( '[MCP] 🎯 OpenAI client detected - filtering tools for deep research only.' );
465 }
466
467 $tools = $this->get_tools_list();
468
469 // Filter tools for OpenAI - they only support search and fetch for deep research
470 if ( $is_openai ) {
471 $filtered_tools = [];
472 foreach ( $tools as $tool ) {
473 if ( in_array( $tool['name'], ['search', 'fetch'] ) ) {
474 $filtered_tools[] = $tool;
475 }
476 }
477 $tools = $filtered_tools;
478
479 if ( $this->logging && count( $filtered_tools ) === 0 ) {
480 error_log( '[MCP] ⚠️ Warning: No search or fetch tools found for OpenAI!' );
481 }
482 }
483
484 $reply = [
485 'jsonrpc' => '2.0',
486 'id' => $id,
487 'result' => [ 'tools' => $tools ],
488 ];
489 if ( $this->logging ) {
490 error_log( '[MCP] 📤 Returning ' . count( $tools ) . ' tools.' );
491 }
492 break;
493
494 case 'tools/call':
495 $params = $data['params'] ?? [];
496 $tool = $params['name'] ?? '';
497 $arguments = $params['arguments']?? [];
498 $reply = $this->execute_tool( $tool, $arguments, $id );
499 break;
500
501 case 'notifications/initialized':
502 // This is a notification from the client indicating it has initialized
503 // No response needed for notifications
504 // Client initialized - no need to log
505 return new WP_REST_Response( null, 204 );
506 break;
507
508 default:
509 // Check if it's a notification (no id)
510 if ( $id === null && strpos( $method, 'notifications/' ) === 0 ) {
511 if ( $this->logging ) {
512 error_log( '[MCP] 📨 Notification received: ' . $method );
513 }
514 return new WP_REST_Response( null, 204 );
515 }
516
517 $reply = [
518 'jsonrpc' => '2.0',
519 'id' => $id,
520 'error' => [ 'code' => -32601, 'message' => "Method not found: {$method}" ]
521 ];
522 }
523
524 // Ensure proper JSON-RPC response
525 $response = new WP_REST_Response( $reply, 200 );
526 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
527 return $response;
528
529 } catch ( Exception $e ) {
530 $error_response = new WP_REST_Response( [
531 'jsonrpc' => '2.0',
532 'id' => $id,
533 'error' => [ 'code' => -32603, 'message' => 'Internal error', 'data' => $e->getMessage() ]
534 ], 200 );
535 $error_response->set_headers( [ 'Content-Type' => 'application/json' ] );
536 return $error_response;
537 }
538 }
539 #endregion
540
541 #region Handle SSE (stream loop)
542 private function reply( string $event, $data = null, string $enc = 'json' ) {
543 // Handle special events
544 if ( $event === 'bye' ) {
545 echo "event: bye\ndata: \n\n";
546 if ( ob_get_level() ) ob_end_flush();
547 flush();
548 $this->last_action_time = time();
549 $this->log( "Clean disconnection" );
550 return;
551 }
552
553 if ( $enc === 'json' && $data === null ) {
554 $this->log( "no data for {$event}");
555 return;
556 }
557 echo "event: {$event}\n";
558 if ( $enc === 'json' )
559 $data = $data === null ? '{}' : wp_json_encode( $data, JSON_UNESCAPED_UNICODE );
560 echo 'data: ' . $data . "\n\n";
561
562 if ( ob_get_level() ) ob_end_flush();
563 flush();
564
565 $this->last_action_time = time();
566 // Only log endpoint announcements
567 if ( $event === 'endpoint' ) {
568 $this->log( "SSE endpoint ready" );
569 }
570 }
571
572 private function generate_sse_id( $req ) {
573 $last = $req ? $req->get_header( 'last-event-id' ) : '';
574 return $last ?: str_replace( '-', '', wp_generate_uuid4() );
575 }
576
577 public function handle_sse( WP_REST_Request $request ) {
578
579 $raw_body = $request->get_body();
580
581 // Handle POST request with JSON-RPC body (Direct MCP client behavior)
582 // Both Claude.ai and OpenAI/ChatGPT send JSON-RPC requests directly to the SSE endpoint
583 // instead of establishing an SSE connection first. This is non-standard but we need to support it.
584 // Expected flow: GET /sse (establish stream) → POST /messages (send JSON-RPC)
585 // Actual flow: POST /sse with JSON-RPC body → expects immediate JSON response
586 if ( $request->get_method() === 'POST' && !empty( $raw_body ) ) {
587 $data = json_decode( $raw_body, true );
588 if ( $data && isset( $data['method'] ) ) {
589 // Don't log here - it's already logged by log_requests()
590 // Process as a direct JSON-RPC request instead of starting SSE stream
591 return $this->handle_direct_jsonrpc( $request, $data );
592 }
593 }
594
595 @ini_set( 'zlib.output_compression', '0' );
596 @ini_set( 'output_buffering', '0' );
597 @ini_set( 'implicit_flush', '1' );
598 if ( function_exists( 'ob_implicit_flush' ) ) ob_implicit_flush( true );
599
600 header( 'Content-Type: text/event-stream' );
601 header( 'Cache-Control: no-cache' );
602 header( 'X-Accel-Buffering: no' );
603 header( 'Connection: keep-alive' );
604 while ( ob_get_level() ) ob_end_flush();
605
606 /* — greet client —*/
607 $this->session_id = $this->generate_sse_id( $request );
608 $this->last_action_time = time();
609 echo "id: {$this->session_id}\n\n";
610 flush();
611
612 $msg_uri = sprintf(
613 '%s/messages?session_id=%s',
614 rest_url( $this->namespace ),
615 $this->session_id
616 );
617 $this->reply( 'endpoint', $msg_uri, 'text' );
618 if ( $this->logging ) {
619 error_log( '[MCP] �
620 SSE connected (' . substr($this->session_id, 0, 8) . '...)' );
621 }
622
623 /* — main loop —*/
624 while ( true ) {
625 // Use shorter timeout in debug mode for easier testing
626 $max_time = $this->logging ? 30 : 60 * 5; // 30 seconds in debug, 5 minutes in production
627 $idle = ( time() - $this->last_action_time ) >= $max_time;
628 if ( connection_aborted() || $idle ) {
629 $this->reply( 'bye' );
630 if ( $this->logging ) {
631 error_log( '[MCP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
632 }
633 break;
634 }
635
636 foreach ( $this->fetch_messages( $this->session_id ) as $p ) {
637 // Check for kill signal in the message queue
638 if ( isset( $p['method'] ) && $p['method'] === 'mwai/kill' ) {
639 if ( $this->logging ) {
640 error_log( '[MCP] Kill signal - terminating' );
641 }
642 $this->reply( 'bye' );
643 exit;
644 }
645
646 // Don't log SSE responses - they clutter the logs
647 $this->reply( 'message', $p );
648 }
649
650 usleep( 200000 ); // 200 ms
651 }
652 exit;
653 }
654 #endregion
655
656 #region Handle /messages (JSON-RPC ingress)
657 public function handle_message( WP_REST_Request $request ) {
658 $sess = sanitize_text_field( $request->get_param( 'session_id' ) );
659 $raw = $request->get_body();
660 $dat = json_decode( $raw, true );
661
662 // Only log important methods in detail
663 if ( $this->logging && $dat && isset( $dat['method'] ) ) {
664 $method = $dat['method'];
665 // Skip logging for repetitive/less important notifications
666 if ( !in_array( $method, ['notifications/initialized', 'notifications/cancelled'] ) ) {
667 error_log( '[MCP] ↓ ' . $method );
668 }
669 }
670
671 if ( json_last_error() !== JSON_ERROR_NONE ) {
672 $this->queue_error( $sess, null, -32700, 'Parse error: invalid JSON' );
673 return new WP_REST_Response( null, 204 );
674 }
675 if ( ! is_array( $dat ) ) {
676 $this->queue_error( $sess, null, -32600, 'Invalid Request' );
677 return new WP_REST_Response( null, 204 );
678 }
679
680 $id = $dat['id'] ?? null;
681 $method = $dat['method'] ?? null;
682
683 /* — notifications —*/
684 if ( $method === 'initialized' ) return new WP_REST_Response( null, 204 );
685 if ( $method === 'mwai/kill' ) {
686 // Kill signal received - no need for verbose logging
687 // Queue the kill message for SSE to pick up before exiting
688 $this->store_message( $sess, [
689 'jsonrpc' => '2.0',
690 'method' => 'mwai/kill'
691 ] );
692 // Give it a moment to be stored
693 usleep( 100000 ); // 100ms
694 return new WP_REST_Response( null, 204 );
695 }
696
697 // It's a notification, no ID = no reply
698 if ( $id === null && $method !== null ) {
699 return new WP_REST_Response( null, 204 );
700 }
701
702 if ( !$method ) {
703 $this->queue_error( $sess, $id, -32600, 'Invalid Request: method missing' );
704 return new WP_REST_Response( null, 204 );
705 }
706
707 try {
708
709 $reply = null;
710
711 #region Methods switch
712 switch ( $method ) {
713
714 case 'initialize':
715 // Check if client requests a specific protocol version
716 $params = $dat['params'] ?? [];
717 $requested_version = $params['protocolVersion'] ?? null;
718 $client_info = $params['clientInfo'] ?? null;
719
720 if ( $this->logging && $client_info ) {
721 $client_name = $client_info['name'] ?? 'unknown';
722 $client_version = $client_info['version'] ?? 'unknown';
723 error_log( "[MCP] Client: {$client_name} v{$client_version}" );
724 }
725
726 if ( $requested_version && $requested_version !== $this->protocol_version ) {
727 if ( $this->logging ) {
728 Meow_MWAI_Logging::warn( "[MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
729 }
730 }
731
732 $reply = [
733 'jsonrpc' => '2.0',
734 'id' => $id,
735 'result' => [
736 'protocolVersion' => $this->protocol_version,
737 'serverInfo' => (object)[
738 'name' => get_bloginfo( 'name' ) . ' MCP',
739 'version' => $this->server_version,
740 ],
741 'capabilities' => [
742 'tools' => [ 'listChanged' => true ],
743 'prompts' => [ 'subscribe' => false, 'listChanged' => false ],
744 'resources' => [ 'subscribe' => false, 'listChanged' => false ],
745 ],
746 ],
747 ];
748 break;
749
750 case 'tools/list':
751 // Don't log every tools/list request as it's too repetitive
752
753 // Check if this is OpenAI by checking the User-Agent
754 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
755 $is_openai = strpos( $user_agent, 'openai-mcp' ) !== false;
756
757 if ( $is_openai && $this->logging ) {
758 error_log( '[MCP] 🎯 OpenAI client detected - filtering tools for deep research only.' );
759 }
760
761 $tools = $this->get_tools_list();
762
763 // Filter tools for OpenAI - they only support search and fetch for deep research
764 if ( $is_openai ) {
765 $filtered_tools = [];
766 foreach ( $tools as $tool ) {
767 if ( in_array( $tool['name'], ['search', 'fetch'] ) ) {
768 $filtered_tools[] = $tool;
769 }
770 }
771 $tools = $filtered_tools;
772
773 if ( $this->logging && count( $filtered_tools ) === 0 ) {
774 error_log( '[MCP] ⚠️ Warning: No search or fetch tools found for OpenAI!' );
775 }
776 }
777
778 $reply = [
779 'jsonrpc' => '2.0',
780 'id' => $id,
781 'result' => [ 'tools' => $tools ],
782 ];
783 break;
784
785 case 'resources/list':
786 $reply = [
787 'jsonrpc' => '2.0',
788 'id' => $id,
789 'result' => [ 'resources' => $this->get_resources_list() ],
790 ];
791 break;
792
793 case 'prompts/list':
794 $reply = [
795 'jsonrpc' => '2.0',
796 'id' => $id,
797 'result' => [ 'prompts' => $this->get_prompts_list() ],
798 ];
799 break;
800
801 case 'tools/call':
802 $params = $dat['params'] ?? [];
803 $tool = $params['name'] ?? '';
804 $arguments = $params['arguments']?? [];
805 $reply = $this->execute_tool( $tool, $arguments, $id );
806 break;
807
808 default:
809 $reply = $this->rpc_error( $id, -32601, "Method not found: {$method}" );
810 }
811 #endregion
812
813 if ( $reply ) {
814 // Don't log response queuing - it's too noisy
815 $this->store_message( $sess, $reply );
816 }
817
818 } catch ( Exception $e ) {
819 $this->queue_error( $sess, $id, -32603, 'Internal error', $e->getMessage() );
820 }
821
822 return new WP_REST_Response( null, 204 );
823 }
824 #endregion
825
826 #region Tools Definitions
827 private function get_tools_list() {
828 $base_tools = [
829 [
830 'name' => 'mcp_ping',
831 '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.',
832 'inputSchema' => [
833 'type' => 'object',
834 'properties' => (object) [],
835 'required' => []
836 ],
837 ],
838 ];
839 return apply_filters( 'mwai_mcp_tools', $base_tools );
840 }
841 #endregion
842
843 #region Resources Definitions
844 private function get_resources_list() {
845 return [];
846 }
847 #endregion
848
849 #region Prompts Definitions
850 private function get_prompts_list() {
851 return [];
852 }
853 #endregion
854
855 #region Tools Call (execute_tool)
856 private function execute_tool( $tool, $args, $id ) {
857 try {
858 // Handle built-in tools first
859 if ( $tool === 'mcp_ping' ) {
860 if ( $this->logging ) {
861 $this->log( '🛠️ Tool: mcp_ping' );
862 }
863 $ping_data = [
864 'time' => gmdate( 'Y-m-d H:i:s' ),
865 'name' => get_bloginfo( 'name' ),
866 ];
867 return [
868 'jsonrpc' => '2.0',
869 'id' => $id,
870 'result' => [
871 'content' => [
872 [
873 'type' => 'text',
874 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
875 ],
876 ],
877 'data' => $ping_data,
878 ],
879 ];
880 }
881
882 // Let other modules handle their tools
883 if ( $this->logging ) {
884 // Log tool calls with more context
885 $args_preview = '';
886 if ( !empty( $args ) ) {
887 // Show key args for common tools
888 if ( isset( $args['ID'] ) ) {
889 $args_preview = ' (ID: ' . $args['ID'] . ')';
890 } elseif ( isset( $args['query'] ) ) {
891 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
892 } elseif ( isset( $args['message'] ) ) {
893 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
894 }
895 }
896 // Log to both error log and UI
897 error_log( '[MCP] 🛠️ ' . $tool . $args_preview );
898 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
899 }
900 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
901
902 if ( $filtered !== null ) {
903 // Check if it's already a full JSON-RPC response (backward compatibility)
904 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
905 return $filtered;
906 }
907
908 // Otherwise, wrap the result in proper JSON-RPC format
909 return [
910 'jsonrpc' => '2.0',
911 'id' => $id,
912 'result' => $this->format_tool_result( $filtered ),
913 ];
914 }
915
916 throw new Exception( "Unknown tool: {$tool}" );
917 } catch ( Exception $e ) {
918 return $this->rpc_error( $id, -32603, $e->getMessage() );
919 }
920 }
921 #endregion
922
923 #region Message Queue (per-message transient)
924 private function transient_key( $sess, $id ) {
925 return "{$this->queue_key}_{$sess}_{$id}";
926 }
927
928 private function store_message( $sess, $payload ) {
929 if ( !$sess ) return;
930 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
931 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
932 $this->log( "queued #{$idKey}" );
933 }
934
935 private function fetch_messages( $sess ) {
936 global $wpdb;
937 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
938
939 $rows = $wpdb->get_results(
940 $wpdb->prepare(
941 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
942 $like
943 ),
944 ARRAY_A
945 );
946
947 $msgs = [];
948 foreach ( $rows as $r ) {
949 $msgs[] = maybe_unserialize( $r['option_value'] );
950 delete_option( $r['option_name'] );
951 }
952 usort( $msgs, fn( $a,$b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
953 if ( $msgs ) $this->log( 'flush '.count($msgs).' msg(s)' );
954 return $msgs;
955 }
956 #endregion
957
958 #region Resources (note)
959 /*--------------------------------------------------*/
960 /**
961 * MCP also supports “resources” – static or dynamic data a client can
962 * retrieve by URL (e.g. `mcp://resource/posts/123`).
963 */
964 #endregion
965 }
966