PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 2.9.2
AI Engine – The Chatbot, AI Framework & MCP for WordPress v2.9.2
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 year ago mcp-rest.php 1 year ago mcp.conf 1 year ago mcp.js 1 year ago mcp.md 1 year ago mcp.php 1 year ago oauth.php 1 year ago realtime.php 1 year ago
mcp.php
988 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 * - OAuth authentication flow is currently disabled due to security concerns
12 * (only static bearer tokens are supported)
13 * - Works with OpenAI/ChatGPT, but OpenAI limits MCP to only 'search' and 'fetch' tools for their Deep Research feature
14 * (these tools are provided by AI Engine's Tuned Core module and search through WordPress posts/pages)
15 *
16 * Direct Connection Challenges:
17 * The goal is to support direct connections from Claude.ai and OpenAI to this MCP server without
18 * requiring the mcp.js relay. However, this is challenging due to:
19 * - PHP's blocking nature causing threads to freeze during long-running SSE connections
20 * - Difficulty in properly handling connection termination and cleanup
21 * - Protocol version differences between clients (e.g., Claude.ai uses 2024-11-05)
22 * - Multiple rapid reconnection attempts from AI services overwhelming the PHP server
23 *
24 * OpenAI Limitations:
25 * - OpenAI's MCP implementation is limited to Deep Research functionality only
26 * - Only 'search' and 'fetch' tools are supported (no other WordPress management tools)
27 * - This significantly limits the MCP capabilities compared to Claude's full implementation
28 *
29 * The mcp.js relay remains the recommended approach for production use until these
30 * direct connection issues are resolved.
31 */
32
33 class Meow_MWAI_Labs_MCP {
34 private $core = null;
35 private $namespace = 'mcp/v1';
36 private $server_version = '0.0.1';
37 private $protocol_version = '2024-11-05';
38 private $queue_key = 'mwai_mcp_msg';
39 private $session_id = null;
40 private $logging = false;
41 private $last_action_time = 0;
42 private $bearer_token = null;
43 // Placeholder for OAuth integration. Currently unused and kept for
44 // future implementation once the security model is revised.
45 private $oauth = null;
46
47 #region Initialize
48 public function __construct( $core ) {
49 $this->core = $core;
50
51 // Set logging based on option
52 $this->logging = $this->core->get_option( 'mcp_debug_mode', false );
53
54 // OAuth support is temporarily disabled due to security concerns.
55 // The previous implementation allowed unvalidated redirect URIs which
56 // introduced an open redirect vulnerability and the possibility to
57 // steal authorization codes. Until proper client registration with
58 // strict redirect URI validation is implemented, the OAuth feature is
59 // not loaded. See labs/oauth.php for the previous code and take care
60 // when re‑enabling it in the future.
61
62 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
63
64 // Log MCP-related requests when logging is enabled
65 if ( $this->logging ) {
66 add_action( 'init', [ $this, 'log_requests' ], 1 );
67 }
68 }
69
70 public function log_requests() {
71 if ( !$this->logging || empty( $_SERVER['REQUEST_METHOD'] ) || empty( $_SERVER['REQUEST_URI'] ) ) {
72 return;
73 }
74
75 $uri = $_SERVER['REQUEST_URI'];
76
77 // Only log MCP-related requests
78 if ( strpos( $uri, '/mcp/' ) === false && strpos( $uri, '/mwai/' ) === false && strpos( $uri, '/.well-known/oauth' ) === false ) {
79 return;
80 }
81
82 // Skip patterns we don't want to log
83 $skip_patterns = [
84 '/wp-admin/',
85 '/wp-cron.php',
86 '/favicon.ico',
87 ];
88
89 foreach ( $skip_patterns as $pattern ) {
90 if ( strpos( $uri, $pattern ) !== false ) {
91 return;
92 }
93 }
94
95 // Get user agent (shortened)
96 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'Unknown';
97 if ( strpos( $user_agent, 'Mozilla' ) !== false ) {
98 $user_agent = 'Mozilla/5.0';
99 }
100 elseif ( strpos( $user_agent, 'python-httpx' ) !== false ) {
101 $user_agent = 'python-httpx/0.27.0';
102 }
103 elseif ( strpos( $user_agent, 'node' ) !== false ) {
104 $user_agent = 'node';
105 }
106
107 // Get IP address (considering proxies)
108 $ip = 'Unknown';
109 if ( !empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
110 $ip = $_SERVER['HTTP_CLIENT_IP'];
111 }
112 elseif ( !empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
113 // X-Forwarded-For can contain multiple IPs, get the first one
114 $ips = explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] );
115 $ip = trim( $ips[0] );
116 }
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 public 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 ) {
335 $err['data'] = $extra;
336 }
337 return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ];
338 }
339
340 /** Queue an error for SSE delivery */
341 private function queue_error( $sess, $id, int $code, string $msg, $extra = null ): void {
342 $this->store_message( $sess, $this->rpc_error( $id, $code, $msg, $extra ) );
343 }
344
345 /** Format tool result for MCP protocol */
346 private function format_tool_result( $result ): array {
347 // If result is a string, wrap it in the MCP content format
348 if ( is_string( $result ) ) {
349 return [
350 'content' => [
351 [
352 'type' => 'text',
353 'text' => $result,
354 ],
355 ],
356 ];
357 }
358
359 // If result has 'content' key, assume it's already properly formatted
360 if ( is_array( $result ) && isset( $result['content'] ) ) {
361 return $result;
362 }
363
364 // If result is an array without 'content' key, wrap it as JSON
365 if ( is_array( $result ) ) {
366 return [
367 'content' => [
368 [
369 'type' => 'text',
370 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT ),
371 ],
372 ],
373 'data' => $result,
374 ];
375 }
376
377 // For any other type, convert to string and wrap
378 return [
379 'content' => [
380 [
381 'type' => 'text',
382 'text' => (string) $result,
383 ],
384 ],
385 ];
386 }
387 #endregion
388
389 #region Handle direct JSON-RPC (for Claude's MCP client)
390 /**
391 * Claude's MCP client (via Anthropic API) sends JSON-RPC requests directly to the SSE endpoint
392 * as POST requests, rather than following the typical SSE flow:
393 * - Normal flow: GET /sse → establish SSE stream → POST /messages for JSON-RPC
394 * - Claude's flow: POST /sse with JSON-RPC body → expect immediate JSON response
395 *
396 * This method handles the direct JSON-RPC requests to maintain compatibility with Claude.
397 */
398 private function handle_direct_jsonrpc( WP_REST_Request $request, $data ) {
399 $id = $data['id'] ?? null;
400 $method = $data['method'] ?? null;
401
402 if ( json_last_error() !== JSON_ERROR_NONE ) {
403 return new WP_REST_Response( [
404 'jsonrpc' => '2.0',
405 'id' => null,
406 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
407 ], 200 );
408 }
409
410 if ( !is_array( $data ) || !$method ) {
411 return new WP_REST_Response( [
412 'jsonrpc' => '2.0',
413 'id' => $id,
414 'error' => [ 'code' => -32600, 'message' => 'Invalid Request' ]
415 ], 200 );
416 }
417
418 try {
419 $reply = null;
420
421 switch ( $method ) {
422 case 'initialize':
423 // Check if client requests a specific protocol version
424 $params = $data['params'] ?? [];
425 $requested_version = $params['protocolVersion'] ?? null;
426 $client_info = $params['clientInfo'] ?? null;
427
428 if ( $this->logging && $client_info ) {
429 $client_name = $client_info['name'] ?? 'unknown';
430 $client_version = $client_info['version'] ?? 'unknown';
431 error_log( "[MCP] Client: {$client_name} v{$client_version}" );
432 }
433
434 if ( $requested_version && $requested_version !== $this->protocol_version ) {
435 if ( $this->logging ) {
436 Meow_MWAI_Logging::warn( "[MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
437 }
438 }
439
440 $reply = [
441 'jsonrpc' => '2.0',
442 'id' => $id,
443 'result' => [
444 'protocolVersion' => $this->protocol_version,
445 'serverInfo' => (object) [
446 'name' => get_bloginfo( 'name' ) . ' MCP',
447 'version' => $this->server_version,
448 ],
449 'capabilities' => [
450 'tools' => [ 'listChanged' => true ],
451 'prompts' => [ 'subscribe' => false, 'listChanged' => false ],
452 'resources' => [ 'subscribe' => false, 'listChanged' => false ],
453 ],
454 ],
455 ];
456 break;
457
458 case 'tools/list':
459 // Don't log every tools/list request as it's too repetitive
460
461 // Check if this is OpenAI by checking the User-Agent
462 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
463 $is_openai = strpos( $user_agent, 'openai-mcp' ) !== false;
464
465 if ( $is_openai && $this->logging ) {
466 error_log( '[MCP] 🎯 OpenAI client detected - filtering tools for deep research only.' );
467 }
468
469 $tools = $this->get_tools_list();
470
471 // Filter tools for OpenAI - they only support search and fetch for deep research
472 if ( $is_openai ) {
473 $filtered_tools = [];
474 foreach ( $tools as $tool ) {
475 if ( in_array( $tool['name'], ['search', 'fetch'] ) ) {
476 $filtered_tools[] = $tool;
477 }
478 }
479 $tools = $filtered_tools;
480
481 if ( $this->logging && count( $filtered_tools ) === 0 ) {
482 error_log( '[MCP] ⚠️ Warning: No search or fetch tools found for OpenAI!' );
483 }
484 }
485
486 $reply = [
487 'jsonrpc' => '2.0',
488 'id' => $id,
489 'result' => [ 'tools' => $tools ],
490 ];
491 if ( $this->logging ) {
492 error_log( '[MCP] 📤 Returning ' . count( $tools ) . ' tools.' );
493 }
494 break;
495
496 case 'tools/call':
497 $params = $data['params'] ?? [];
498 $tool = $params['name'] ?? '';
499 $arguments = $params['arguments'] ?? [];
500 $reply = $this->execute_tool( $tool, $arguments, $id );
501 break;
502
503 case 'notifications/initialized':
504 // This is a notification from the client indicating it has initialized
505 // No response needed for notifications
506 // Client initialized - no need to log
507 return new WP_REST_Response( null, 204 );
508 break;
509
510 default:
511 // Check if it's a notification (no id)
512 if ( $id === null && strpos( $method, 'notifications/' ) === 0 ) {
513 if ( $this->logging ) {
514 error_log( '[MCP] 📨 Notification received: ' . $method );
515 }
516 return new WP_REST_Response( null, 204 );
517 }
518
519 $reply = [
520 'jsonrpc' => '2.0',
521 'id' => $id,
522 'error' => [ 'code' => -32601, 'message' => "Method not found: {$method}" ]
523 ];
524 }
525
526 // Ensure proper JSON-RPC response
527 $response = new WP_REST_Response( $reply, 200 );
528 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
529 return $response;
530
531 }
532 catch ( Exception $e ) {
533 $error_response = new WP_REST_Response( [
534 'jsonrpc' => '2.0',
535 'id' => $id,
536 'error' => [ 'code' => -32603, 'message' => 'Internal error', 'data' => $e->getMessage() ]
537 ], 200 );
538 $error_response->set_headers( [ 'Content-Type' => 'application/json' ] );
539 return $error_response;
540 }
541 }
542 #endregion
543
544 #region Handle SSE (stream loop)
545 private function reply( string $event, $data = null, string $enc = 'json' ) {
546 // Handle special events
547 if ( $event === 'bye' ) {
548 echo "event: bye\ndata: \n\n";
549 if ( ob_get_level() ) {
550 ob_end_flush();
551 }
552 flush();
553 $this->last_action_time = time();
554 $this->log( 'Clean disconnection' );
555 return;
556 }
557
558 if ( $enc === 'json' && $data === null ) {
559 $this->log( "no data for {$event}" );
560 return;
561 }
562 echo "event: {$event}\n";
563 if ( $enc === 'json' ) {
564 $data = $data === null ? '{}' : wp_json_encode( $data, JSON_UNESCAPED_UNICODE );
565 }
566 echo 'data: ' . $data . "\n\n";
567
568 if ( ob_get_level() ) {
569 ob_end_flush();
570 }
571 flush();
572
573 $this->last_action_time = time();
574 // Only log endpoint announcements
575 if ( $event === 'endpoint' ) {
576 $this->log( 'SSE endpoint ready' );
577 }
578 }
579
580 private function generate_sse_id( $req ) {
581 $last = $req ? $req->get_header( 'last-event-id' ) : '';
582 return $last ?: str_replace( '-', '', wp_generate_uuid4() );
583 }
584
585 public function handle_sse( WP_REST_Request $request ) {
586
587 $raw_body = $request->get_body();
588
589 // Handle POST request with JSON-RPC body (Direct MCP client behavior)
590 // Both Claude.ai and OpenAI/ChatGPT send JSON-RPC requests directly to the SSE endpoint
591 // instead of establishing an SSE connection first. This is non-standard but we need to support it.
592 // Expected flow: GET /sse (establish stream) → POST /messages (send JSON-RPC)
593 // Actual flow: POST /sse with JSON-RPC body → expects immediate JSON response
594 if ( $request->get_method() === 'POST' && !empty( $raw_body ) ) {
595 $data = json_decode( $raw_body, true );
596 if ( $data && isset( $data['method'] ) ) {
597 // Don't log here - it's already logged by log_requests()
598 // Process as a direct JSON-RPC request instead of starting SSE stream
599 return $this->handle_direct_jsonrpc( $request, $data );
600 }
601 }
602
603 @ini_set( 'zlib.output_compression', '0' );
604 @ini_set( 'output_buffering', '0' );
605 @ini_set( 'implicit_flush', '1' );
606 if ( function_exists( 'ob_implicit_flush' ) ) {
607 ob_implicit_flush( true );
608 }
609
610 header( 'Content-Type: text/event-stream' );
611 header( 'Cache-Control: no-cache' );
612 header( 'X-Accel-Buffering: no' );
613 header( 'Connection: keep-alive' );
614 while ( ob_get_level() ) {
615 ob_end_flush();
616 }
617
618 /* — greet client —*/
619 $this->session_id = $this->generate_sse_id( $request );
620 $this->last_action_time = time();
621 echo "id: {$this->session_id}\n\n";
622 flush();
623
624 $msg_uri = sprintf(
625 '%s/messages?session_id=%s',
626 rest_url( $this->namespace ),
627 $this->session_id
628 );
629 $this->reply( 'endpoint', $msg_uri, 'text' );
630 if ( $this->logging ) {
631 error_log( '[MCP] �
632 SSE connected (' . substr( $this->session_id, 0, 8 ) . '...)' );
633 }
634
635 /* — main loop —*/
636 while ( true ) {
637 // Use shorter timeout in debug mode for easier testing
638 $max_time = $this->logging ? 30 : 60 * 5; // 30 seconds in debug, 5 minutes in production
639 $idle = ( time() - $this->last_action_time ) >= $max_time;
640 if ( connection_aborted() || $idle ) {
641 $this->reply( 'bye' );
642 if ( $this->logging ) {
643 error_log( '[MCP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
644 }
645 break;
646 }
647
648 foreach ( $this->fetch_messages( $this->session_id ) as $p ) {
649 // Check for kill signal in the message queue
650 if ( isset( $p['method'] ) && $p['method'] === 'mwai/kill' ) {
651 if ( $this->logging ) {
652 error_log( '[MCP] Kill signal - terminating' );
653 }
654 $this->reply( 'bye' );
655 exit;
656 }
657
658 // Don't log SSE responses - they clutter the logs
659 $this->reply( 'message', $p );
660 }
661
662 usleep( 200000 ); // 200 ms
663 }
664 exit;
665 }
666 #endregion
667
668 #region Handle /messages (JSON-RPC ingress)
669 public function handle_message( WP_REST_Request $request ) {
670 $sess = sanitize_text_field( $request->get_param( 'session_id' ) );
671 $raw = $request->get_body();
672 $dat = json_decode( $raw, true );
673
674 // Only log important methods in detail
675 if ( $this->logging && $dat && isset( $dat['method'] ) ) {
676 $method = $dat['method'];
677 // Skip logging for repetitive/less important notifications
678 if ( !in_array( $method, ['notifications/initialized', 'notifications/cancelled'] ) ) {
679 error_log( '[MCP] ↓ ' . $method );
680 }
681 }
682
683 if ( json_last_error() !== JSON_ERROR_NONE ) {
684 $this->queue_error( $sess, null, -32700, 'Parse error: invalid JSON' );
685 return new WP_REST_Response( null, 204 );
686 }
687 if ( !is_array( $dat ) ) {
688 $this->queue_error( $sess, null, -32600, 'Invalid Request' );
689 return new WP_REST_Response( null, 204 );
690 }
691
692 $id = $dat['id'] ?? null;
693 $method = $dat['method'] ?? null;
694
695 /* — notifications —*/
696 if ( $method === 'initialized' ) {
697 return new WP_REST_Response( null, 204 );
698 }
699 if ( $method === 'mwai/kill' ) {
700 // Kill signal received - no need for verbose logging
701 // Queue the kill message for SSE to pick up before exiting
702 $this->store_message( $sess, [
703 'jsonrpc' => '2.0',
704 'method' => 'mwai/kill'
705 ] );
706 // Give it a moment to be stored
707 usleep( 100000 ); // 100ms
708 return new WP_REST_Response( null, 204 );
709 }
710
711 // It's a notification, no ID = no reply
712 if ( $id === null && $method !== null ) {
713 return new WP_REST_Response( null, 204 );
714 }
715
716 if ( !$method ) {
717 $this->queue_error( $sess, $id, -32600, 'Invalid Request: method missing' );
718 return new WP_REST_Response( null, 204 );
719 }
720
721 try {
722
723 $reply = null;
724
725 #region Methods switch
726 switch ( $method ) {
727
728 case 'initialize':
729 // Check if client requests a specific protocol version
730 $params = $dat['params'] ?? [];
731 $requested_version = $params['protocolVersion'] ?? null;
732 $client_info = $params['clientInfo'] ?? null;
733
734 if ( $this->logging && $client_info ) {
735 $client_name = $client_info['name'] ?? 'unknown';
736 $client_version = $client_info['version'] ?? 'unknown';
737 error_log( "[MCP] Client: {$client_name} v{$client_version}" );
738 }
739
740 if ( $requested_version && $requested_version !== $this->protocol_version ) {
741 if ( $this->logging ) {
742 Meow_MWAI_Logging::warn( "[MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" );
743 }
744 }
745
746 $reply = [
747 'jsonrpc' => '2.0',
748 'id' => $id,
749 'result' => [
750 'protocolVersion' => $this->protocol_version,
751 'serverInfo' => (object) [
752 'name' => get_bloginfo( 'name' ) . ' MCP',
753 'version' => $this->server_version,
754 ],
755 'capabilities' => [
756 'tools' => [ 'listChanged' => true ],
757 'prompts' => [ 'subscribe' => false, 'listChanged' => false ],
758 'resources' => [ 'subscribe' => false, 'listChanged' => false ],
759 ],
760 ],
761 ];
762 break;
763
764 case 'tools/list':
765 // Don't log every tools/list request as it's too repetitive
766
767 // Check if this is OpenAI by checking the User-Agent
768 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : '';
769 $is_openai = strpos( $user_agent, 'openai-mcp' ) !== false;
770
771 if ( $is_openai && $this->logging ) {
772 error_log( '[MCP] 🎯 OpenAI client detected - filtering tools for deep research only.' );
773 }
774
775 $tools = $this->get_tools_list();
776
777 // Filter tools for OpenAI - they only support search and fetch for deep research
778 if ( $is_openai ) {
779 $filtered_tools = [];
780 foreach ( $tools as $tool ) {
781 if ( in_array( $tool['name'], ['search', 'fetch'] ) ) {
782 $filtered_tools[] = $tool;
783 }
784 }
785 $tools = $filtered_tools;
786
787 if ( $this->logging && count( $filtered_tools ) === 0 ) {
788 error_log( '[MCP] ⚠️ Warning: No search or fetch tools found for OpenAI!' );
789 }
790 }
791
792 $reply = [
793 'jsonrpc' => '2.0',
794 'id' => $id,
795 'result' => [ 'tools' => $tools ],
796 ];
797 break;
798
799 case 'resources/list':
800 $reply = [
801 'jsonrpc' => '2.0',
802 'id' => $id,
803 'result' => [ 'resources' => $this->get_resources_list() ],
804 ];
805 break;
806
807 case 'prompts/list':
808 $reply = [
809 'jsonrpc' => '2.0',
810 'id' => $id,
811 'result' => [ 'prompts' => $this->get_prompts_list() ],
812 ];
813 break;
814
815 case 'tools/call':
816 $params = $dat['params'] ?? [];
817 $tool = $params['name'] ?? '';
818 $arguments = $params['arguments'] ?? [];
819 $reply = $this->execute_tool( $tool, $arguments, $id );
820 break;
821
822 default:
823 $reply = $this->rpc_error( $id, -32601, "Method not found: {$method}" );
824 }
825 #endregion
826
827 if ( $reply ) {
828 // Don't log response queuing - it's too noisy
829 $this->store_message( $sess, $reply );
830 }
831
832 }
833 catch ( Exception $e ) {
834 $this->queue_error( $sess, $id, -32603, 'Internal error', $e->getMessage() );
835 }
836
837 return new WP_REST_Response( null, 204 );
838 }
839 #endregion
840
841 #region Tools Definitions
842 private function get_tools_list() {
843 $base_tools = [
844 [
845 'name' => 'mcp_ping',
846 '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.',
847 'inputSchema' => [
848 'type' => 'object',
849 'properties' => (object) [],
850 'required' => []
851 ],
852 ],
853 ];
854 return apply_filters( 'mwai_mcp_tools', $base_tools );
855 }
856 #endregion
857
858 #region Resources Definitions
859 private function get_resources_list() {
860 return [];
861 }
862 #endregion
863
864 #region Prompts Definitions
865 private function get_prompts_list() {
866 return [];
867 }
868 #endregion
869
870 #region Tools Call (execute_tool)
871 private function execute_tool( $tool, $args, $id ) {
872 try {
873 // Handle built-in tools first
874 if ( $tool === 'mcp_ping' ) {
875 if ( $this->logging ) {
876 $this->log( '🛠️ Tool: mcp_ping' );
877 }
878 $ping_data = [
879 'time' => gmdate( 'Y-m-d H:i:s' ),
880 'name' => get_bloginfo( 'name' ),
881 ];
882 return [
883 'jsonrpc' => '2.0',
884 'id' => $id,
885 'result' => [
886 'content' => [
887 [
888 'type' => 'text',
889 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
890 ],
891 ],
892 'data' => $ping_data,
893 ],
894 ];
895 }
896
897 // Let other modules handle their tools
898 if ( $this->logging ) {
899 // Log tool calls with more context
900 $args_preview = '';
901 if ( !empty( $args ) ) {
902 // Show key args for common tools
903 if ( isset( $args['ID'] ) ) {
904 $args_preview = ' (ID: ' . $args['ID'] . ')';
905 }
906 elseif ( isset( $args['query'] ) ) {
907 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
908 }
909 elseif ( isset( $args['message'] ) ) {
910 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
911 }
912 }
913 // Log to both error log and UI
914 error_log( '[MCP] 🛠️ ' . $tool . $args_preview );
915 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
916 }
917 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
918
919 if ( $filtered !== null ) {
920 // Check if it's already a full JSON-RPC response (backward compatibility)
921 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
922 return $filtered;
923 }
924
925 // Otherwise, wrap the result in proper JSON-RPC format
926 return [
927 'jsonrpc' => '2.0',
928 'id' => $id,
929 'result' => $this->format_tool_result( $filtered ),
930 ];
931 }
932
933 throw new Exception( "Unknown tool: {$tool}" );
934 }
935 catch ( Exception $e ) {
936 return $this->rpc_error( $id, -32603, $e->getMessage() );
937 }
938 }
939 #endregion
940
941 #region Message Queue (per-message transient)
942 private function transient_key( $sess, $id ) {
943 return "{$this->queue_key}_{$sess}_{$id}";
944 }
945
946 private function store_message( $sess, $payload ) {
947 if ( !$sess ) {
948 return;
949 }
950 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
951 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
952 $this->log( "queued #{$idKey}" );
953 }
954
955 private function fetch_messages( $sess ) {
956 global $wpdb;
957 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
958
959 $rows = $wpdb->get_results(
960 $wpdb->prepare(
961 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
962 $like
963 ),
964 ARRAY_A
965 );
966
967 $msgs = [];
968 foreach ( $rows as $r ) {
969 $msgs[] = maybe_unserialize( $r['option_value'] );
970 delete_option( $r['option_name'] );
971 }
972 usort( $msgs, fn ( $a, $b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
973 if ( $msgs ) {
974 $this->log( 'flush ' . count( $msgs ) . ' msg(s)' );
975 }
976 return $msgs;
977 }
978 #endregion
979
980 #region Resources (note)
981 /*--------------------------------------------------*/
982 /**
983 * MCP also supports “resources” – static or dynamic data a client can
984 * retrieve by URL (e.g. `mcp://resource/posts/123`).
985 */
986 #endregion
987 }
988