PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.5.6
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.5.6
3.7.4 3.7.3 3.7.2 3.7.1 3.7.0 3.6.9 3.6.8 3.6.7 3.6.6 3.6.4 3.6.5 3.6.3 3.6.2 3.6.1 3.6.0 3.5.9 3.5.8 3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / labs / mcp.php
ai-engine / labs Last commit date
mcp-core.php 2 months ago mcp-oauth.php 3 months ago mcp-rest.php 2 months ago mcp.conf 1 year ago mcp.js 10 months ago mcp.md 10 months ago mcp.php 2 months ago wpai-connectors.php 3 months ago wpai-gateway-availability.php 4 months ago wpai-gateway-directory.php 4 months ago wpai-gateway-image-model.php 4 months ago wpai-gateway-model.php 4 months ago wpai-gateway-providers.php 4 months ago wpai-gateway.php 4 months ago
mcp.php
1200 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';
29 private $supported_protocol_versions = [ '2024-11-05', '2025-06-18' ];
30 private $queue_key = 'mwai_mcp_msg';
31 private $session_id = null;
32 private $logging = false;
33 private $last_action_time = 0;
34 private $bearer_token = null;
35 private $mcp_role = 'admin';
36 private $tool_access_levels = [];
37 // Placeholder for OAuth integration. Currently unused and kept for
38 // future implementation once the security model is revised.
39 private $oauth = null;
40 // Resolved during auth so the MCP Logs feature can attribute tool calls
41 // to a specific connector (Claude, ChatGPT, Claude Code, …) or 'bearer'.
42 // Lives on the instance for the duration of one HTTP request.
43 private $auth_client_id = null;
44 private $auth_client_name = null;
45 private $auth_method = null; // 'oauth' | 'bearer' | 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 2.1 with Dynamic Client Registration. Lives alongside the bearer
55 // token: bearer is for dev tools (Claude Code, scripts), OAuth is for
56 // browser-driven clients like Claude Desktop. The new module enforces
57 // strict redirect_uri matching, PKCE S256, and refresh-token rotation.
58 require_once __DIR__ . '/mcp-oauth.php';
59 $this->oauth = new Meow_MWAI_Labs_MCP_OAuth( $core, $this );
60
61 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
62 }
63
64 public function is_logging_enabled() {
65 return $this->logging;
66 }
67
68 public function rest_api_init() {
69 // Load bearer token if not already loaded
70 if ( $this->bearer_token === null ) {
71 $this->bearer_token = $this->core->get_option( 'mcp_bearer_token' );
72 }
73 $this->mcp_role = $this->core->get_option( 'mcp_role', 'admin' );
74
75 // Auth filter runs for both bearer token and OAuth token paths; register
76 // unconditionally so that OAuth-only deployments (no static bearer set) work.
77 static $filter_added = false;
78 if ( !$filter_added ) {
79 add_filter( 'mwai_allow_mcp', [ $this, 'auth_via_bearer_token' ], 10, 2 );
80 $filter_added = true;
81 }
82
83 // Extend the CORS allow-headers list for our MCP routes. The Streamable HTTP
84 // transport sends Mcp-Protocol-Version and Mcp-Session-Id on every request;
85 // WP core's default allow-list does not include them, so the browser-side
86 // preflight from claude.ai (and similar web connectors) was rejecting the
87 // actual POST and the client reported "Couldn't reach the MCP server".
88 add_filter( 'rest_allowed_cors_headers', function ( $headers ) {
89 $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : '';
90 if ( strpos( $uri, '/' . $this->namespace . '/' ) === false ) {
91 return $headers;
92 }
93 foreach ( [ 'Mcp-Protocol-Version', 'Mcp-Session-Id', 'Accept' ] as $h ) {
94 if ( !in_array( $h, $headers, true ) ) {
95 $headers[] = $h;
96 }
97 }
98 return $headers;
99 } );
100
101 // Streamable HTTP endpoint (modern MCP transport). Always registered when
102 // the MCP module is enabled — auth is enforced by can_access_mcp(), which
103 // accepts either a bearer token or an OAuth access token.
104 register_rest_route( $this->namespace, '/http', [
105 'methods' => [ 'GET', 'POST', 'DELETE' ],
106 'callback' => [ $this, 'handle_streamable_http' ],
107 'permission_callback' => function ( $request ) {
108 return $this->can_access_mcp( $request );
109 },
110 'show_in_index' => false,
111 ] );
112
113 // Alternative endpoint with bearer token embedded in URL path, for clients
114 // that cannot send Authorization headers. Only registered when a bearer
115 // token is configured.
116 if ( !empty( $this->bearer_token ) ) {
117 register_rest_route( $this->namespace, '/' . $this->bearer_token, [
118 'methods' => [ 'GET', 'POST', 'DELETE' ],
119 'callback' => [ $this, 'handle_streamable_http' ],
120 'permission_callback' => function ( $request ) {
121 return $this->handle_noauth_access_streamable( $request );
122 },
123 'show_in_index' => false,
124 ] );
125 }
126
127 // File upload endpoint for wp_upload_request
128 // Uses a one-time token in the URL for authentication (no bearer header needed from curl)
129 register_rest_route( $this->namespace, '/upload/(?P<token>[a-zA-Z0-9]+)', [
130 'methods' => 'POST',
131 'callback' => [ $this, 'handle_upload' ],
132 'permission_callback' => '__return_true',
133 'show_in_index' => false,
134 ] );
135 }
136 #endregion
137
138 #region Auth (Bearer token)
139 /**
140 * SECURITY: MCP provides powerful WordPress management capabilities, so access must be strictly controlled.
141 *
142 * By default, only administrators can access MCP endpoints. This prevents lower-privileged users
143 * (subscribers, contributors, etc.) from executing dangerous operations like creating admin users,
144 * deleting content, or modifying settings.
145 *
146 * When a bearer token is configured, it overrides the default admin check, but access is DENIED
147 * unless a valid token is provided. This ensures MCP is secure even with default settings.
148 */
149 public function can_access_mcp( $request ) {
150 // Default to requiring administrator capability for security
151 $is_admin = current_user_can( 'administrator' );
152 return apply_filters( 'mwai_allow_mcp', $is_admin, $request );
153 }
154
155 public function auth_via_bearer_token( $allow, $request ) {
156 // Skip if already authenticated as admin
157 if ( $allow ) {
158 return $allow;
159 }
160
161 $hdr = $request->get_header( 'authorization' );
162
163 // If no authorization header but bearer token is configured, deny access
164 if ( !$hdr && !empty( $this->bearer_token ) ) {
165 if ( $this->logging ) {
166 error_log( '[AI Engine MCP] ❌ No authorization header provided. Server may be stripping headers.' );
167 }
168 return false;
169 }
170
171 // Check for Bearer token in header
172 if ( $hdr && preg_match( '/Bearer\s+(.+)/i', $hdr, $m ) ) {
173 $token = trim( $m[1] );
174 $auth_result = 'none';
175
176 // Check if it's an OAuth token
177 if ( $this->oauth ) {
178 $token_data = $this->oauth->validate_token( $token );
179 if ( $token_data ) {
180 // Defense in depth: even if a token was issued (or stored from before
181 // the authorize-time admin gate landed), only accept it if the linked
182 // user still holds administrator capability. Otherwise a Subscriber's
183 // OAuth token would inherit the global mcp_role and reach admin tools.
184 if ( !$this->oauth->user_can_authorize( $token_data['user_id'] ) ) {
185 if ( $this->logging ) {
186 error_log( '[AI Engine MCP] ❌ OAuth token rejected: user ' . $token_data['user_id'] . ' is not an administrator.' );
187 }
188 return false;
189 }
190 // Set current user based on OAuth token
191 wp_set_current_user( $token_data['user_id'] );
192 $auth_result = 'oauth';
193 $this->auth_method = 'oauth';
194 $this->auth_client_id = $token_data['client_id'] ?? null;
195 $this->auth_client_name = $token_data['client_name'] ?? null;
196 return true;
197 }
198 }
199
200 // Fall back to static bearer token if configured
201 if ( !empty( $this->bearer_token ) && hash_equals( $this->bearer_token, $token ) ) {
202 if ( $admin = $this->core->get_admin_user() ) {
203 wp_set_current_user( $admin->ID, $admin->user_login );
204 }
205 $auth_result = 'static';
206 $this->auth_method = 'bearer';
207 $this->auth_client_id = 'bearer';
208 $this->auth_client_name = null;
209 if ( $this->logging ) {
210 error_log( '[AI Engine MCP] 🔐 Bearer token auth OK' );
211 }
212 return true;
213 }
214
215 if ( $this->logging && $auth_result === 'none' ) {
216 error_log( '[AI Engine MCP] ❌ Bearer token invalid.' );
217 }
218 // Explicitly deny access for invalid tokens
219 return false;
220 }
221
222 // ?token=xyz fallback (optional) - only for static bearer token
223 if ( !empty( $this->bearer_token ) ) {
224 $q = sanitize_text_field( $request->get_param( 'token' ) );
225 if ( $q && hash_equals( $this->bearer_token, $q ) ) {
226 if ( $admin = $this->core->get_admin_user() ) {
227 wp_set_current_user( $admin->ID, $admin->user_login );
228 }
229 $this->auth_method = 'bearer';
230 $this->auth_client_id = 'bearer';
231 return true;
232 }
233 }
234
235 // If bearer token is configured but no valid auth provided, deny access
236 if ( !empty( $this->bearer_token ) ) {
237 return false;
238 }
239
240 return $allow;
241 }
242
243 public function handle_noauth_access_streamable( $request ) {
244 // For Streamable HTTP with token in URL path (no trailing slash)
245 $route = $request->get_route();
246 $expected = '/' . $this->namespace . '/' . $this->bearer_token;
247 if ( $route !== $expected ) {
248 if ( $this->logging ) {
249 error_log( '[AI Engine MCP] ❌ Invalid Streamable HTTP no-auth URL access attempt.' );
250 }
251 return false;
252 }
253
254 // Set the current user to admin since token is valid
255 if ( $admin = $this->core->get_admin_user() ) {
256 wp_set_current_user( $admin->ID, $admin->user_login );
257 }
258 $this->auth_method = 'bearer';
259 $this->auth_client_id = 'bearer';
260 return true;
261 }
262
263 #endregion
264
265 #region Helpers (log / JSON-RPC utils)
266 /**
267 * Release the PHP session lock as early as possible. Long MCP calls (e.g. content
268 * mutations on large posts) can otherwise serialize behind any other request from the
269 * same client that opened a session, since PHP holds an exclusive write lock on the
270 * session file for the lifetime of the request. The result is the ~max_execution_time
271 * hangs operators see on busy sites. Closing the session is idempotent and safe — if
272 * no session is active the call is a no-op.
273 */
274 private function release_session_lock(): void {
275 if ( function_exists( 'session_status' ) && session_status() === PHP_SESSION_ACTIVE ) {
276 session_write_close();
277 }
278 }
279
280 private function log( $msg ) {
281 // This method is for internal UI logs - keep it minimal
282 if ( $this->logging ) {
283 // Only log important messages to UI
284 if ( strpos( $msg, 'queued' ) === false && strpos( $msg, 'flush' ) === false ) {
285 Meow_MWAI_Logging::log( "[AI Engine MCP] {$msg}" );
286 }
287 }
288 }
289
290 /** Wrap a JSON-RPC error object */
291 private function rpc_error( $id, int $code, string $msg, $extra = null ): array {
292 $err = [ 'code' => $code, 'message' => $msg ];
293 if ( $extra !== null ) {
294 $err['data'] = $extra;
295 }
296 return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ];
297 }
298
299 /** Format tool result for MCP protocol */
300 private function format_tool_result( $result ): array {
301 // If result is a string, wrap it in the MCP content format
302 if ( is_string( $result ) ) {
303 return [
304 'content' => [
305 [
306 'type' => 'text',
307 'text' => $result,
308 ],
309 ],
310 ];
311 }
312
313 // If result has 'content' key, assume it's already properly formatted
314 if ( is_array( $result ) && isset( $result['content'] ) ) {
315 return $result;
316 }
317
318 // If result is an array without 'content' key, wrap it as JSON
319 if ( is_array( $result ) ) {
320 return [
321 'content' => [
322 [
323 'type' => 'text',
324 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT ),
325 ],
326 ],
327 'data' => $result,
328 ];
329 }
330
331 // For any other type, convert to string and wrap
332 return [
333 'content' => [
334 [
335 'type' => 'text',
336 'text' => (string) $result,
337 ],
338 ],
339 ];
340 }
341 #endregion
342
343 #region Handle direct JSON-RPC
344 /**
345 * Shared JSON-RPC processor: takes a decoded request body, dispatches the method,
346 * and returns an immediate WP_REST_Response. Used by the Streamable HTTP POST handler
347 * (the modern transport for Claude Desktop, Claude.ai, ChatGPT, Claude Code).
348 */
349 private function handle_direct_jsonrpc( WP_REST_Request $request, $data ) {
350 $this->release_session_lock();
351 $id = $data['id'] ?? null;
352 $method = $data['method'] ?? null;
353
354 if ( json_last_error() !== JSON_ERROR_NONE ) {
355 $response = new WP_REST_Response( [
356 'jsonrpc' => '2.0',
357 'id' => null,
358 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
359 ], 200 );
360 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
361 $session_header = $request->get_header( 'mcp-session-id' );
362 if ( !empty( $session_header ) ) {
363 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
364 }
365 return $response;
366 }
367
368 if ( !is_array( $data ) || !$method ) {
369 $response = new WP_REST_Response( [
370 'jsonrpc' => '2.0',
371 'id' => $id,
372 'error' => [ 'code' => -32600, 'message' => 'Invalid Request' ]
373 ], 200 );
374 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
375 $session_header = $request->get_header( 'mcp-session-id' );
376 if ( !empty( $session_header ) ) {
377 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
378 }
379 return $response;
380 }
381
382 $session_header = $request->get_header( 'mcp-session-id' );
383 $session_id = '';
384 if ( !empty( $session_header ) ) {
385 $session_id = sanitize_text_field( $session_header );
386 }
387
388 if ( $method === 'initialize' || empty( $session_id ) ) {
389 $session_id = wp_generate_uuid4();
390 if ( $this->logging ) {
391 error_log( '[AI Engine MCP] 🆔 Direct session initialized: ' . $session_id );
392 }
393 }
394
395 try {
396 $reply = null;
397
398 switch ( $method ) {
399 case 'initialize':
400 // Check if client requests a specific protocol version
401 $params = $data['params'] ?? [];
402 $requested_version = $params['protocolVersion'] ?? null;
403 $client_info = $params['clientInfo'] ?? null;
404
405 if ( $this->logging && $client_info ) {
406 $client_name = $client_info['name'] ?? 'unknown';
407 $client_version = $client_info['version'] ?? 'unknown';
408 error_log( "[AI Engine MCP] Client: {$client_name} v{$client_version}" );
409 }
410
411 // Negotiate protocol version: use client's version if supported
412 $negotiated_version = $this->protocol_version;
413 if ( $requested_version && in_array( $requested_version, $this->supported_protocol_versions, true ) ) {
414 $negotiated_version = $requested_version;
415 }
416 else if ( $requested_version && $requested_version !== $this->protocol_version ) {
417 if ( $this->logging ) {
418 Meow_MWAI_Logging::warn( "[AI Engine MCP] Client requested unsupported protocol version {$requested_version}" );
419 }
420 }
421
422 $reply = [
423 'jsonrpc' => '2.0',
424 'id' => $id,
425 'result' => [
426 'protocolVersion' => $negotiated_version,
427 'serverInfo' => (object) [
428 'name' => 'AI Engine - ' . get_bloginfo( 'name' ),
429 'version' => $this->server_version,
430 ],
431 'capabilities' => (object) [
432 'tools' => new stdClass(),
433 ],
434 ],
435 ];
436 break;
437
438 case 'tools/list':
439 $tools = $this->get_tools_list();
440
441 // Debug logging for tools/list
442 if ( $this->logging ) {
443 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
444 error_log( '[AI Engine MCP Direct] 📋 tools/list requested by: ' . $user_agent );
445 error_log( '[AI Engine MCP Direct] 📊 Returning ' . count( $tools ) . ' tools' );
446 if ( count( $tools ) > 0 ) {
447 $tool_names = array_column( $tools, 'name' );
448 error_log( '[AI Engine MCP Direct] 🛠️ Tool names: ' . implode( ', ', $tool_names ) );
449 }
450 else {
451 error_log( '[AI Engine MCP Direct] ⚠️ WARNING: No tools returned!' );
452 }
453 }
454
455 $reply = [
456 'jsonrpc' => '2.0',
457 'id' => $id,
458 'result' => [ 'tools' => $tools ],
459 ];
460 break;
461
462 case 'tools/call':
463 $params = $data['params'] ?? [];
464 $tool = $params['name'] ?? '';
465 $arguments = $params['arguments'] ?? [];
466
467 if ( $this->logging ) {
468 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Tool: ' . $tool );
469 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Arguments: ' . wp_json_encode( $arguments ) );
470 }
471
472 try {
473 $reply = $this->execute_tool( $tool, $arguments, $id );
474 if ( $this->logging ) {
475 error_log( '[AI Engine MCP Direct] �
476 tools/call - Success for tool: ' . $tool );
477 }
478 }
479 catch ( Exception $e ) {
480 if ( $this->logging ) {
481 error_log( '[AI Engine MCP Direct] tools/call - Error: ' . $e->getMessage() );
482 }
483 throw $e;
484 }
485 break;
486
487 case 'notifications/initialized':
488 // This is a notification from the client indicating it has initialized
489 // No response needed for notifications
490 // Client initialized - no need to log
491 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
492 break;
493
494 default:
495 // Check if it's a notification (no id)
496 if ( $id === null && strpos( $method, 'notifications/' ) === 0 ) {
497 if ( $this->logging ) {
498 error_log( '[AI Engine MCP] 📨 Notification received: ' . $method );
499 }
500 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
501 }
502
503 $reply = [
504 'jsonrpc' => '2.0',
505 'id' => $id,
506 'error' => [ 'code' => -32601, 'message' => "Method not found: {$method}" ]
507 ];
508 }
509
510 // Ensure proper JSON-RPC response
511 $response = new WP_REST_Response( $reply, 200 );
512 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
513 return $this->attach_session_header( $response, $session_id );
514
515 }
516 catch ( Exception $e ) {
517 if ( $this->logging ) {
518 error_log( '[AI Engine MCP] ❌ Exception in handle_direct_jsonrpc: ' . $e->getMessage() );
519 }
520
521 $error_response = new WP_REST_Response( [
522 'jsonrpc' => '2.0',
523 'id' => $id,
524 'error' => [ 'code' => -32603, 'message' => 'Internal error', 'data' => $e->getMessage() ]
525 ], 200 );
526 $error_response->set_headers( [ 'Content-Type' => 'application/json' ] );
527 return $this->attach_session_header( $error_response, $session_id );
528 }
529 }
530 #endregion
531
532 #region Session helpers
533 private function attach_session_header( WP_REST_Response $response, string $session_id ) {
534 if ( empty( $session_id ) ) {
535 return $response;
536 }
537
538 $response->header( 'Mcp-Session-Id', $session_id );
539
540 if ( $this->logging ) {
541 error_log( '[AI Engine MCP] 🪪 Response session header: ' . $session_id );
542 }
543
544 return $response;
545 }
546 #endregion
547
548 #region Handle Streamable HTTP (Modern MCP transport)
549 /**
550 * Handle Streamable HTTP requests per MCP specification.
551 * This is the modern transport used by Claude Code and other MCP clients.
552 *
553 * - POST: Send JSON-RPC request, receive JSON response (or SSE for streaming)
554 * - GET: Open SSE stream for server-initiated messages
555 * - DELETE: Terminate the session
556 *
557 * @see https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
558 */
559 public function handle_streamable_http( WP_REST_Request $request ) {
560 $method = $request->get_method();
561
562 switch ( $method ) {
563 case 'POST':
564 return $this->handle_streamable_http_post( $request );
565
566 case 'GET':
567 return $this->handle_streamable_http_get( $request );
568
569 case 'DELETE':
570 return $this->handle_streamable_http_delete( $request );
571
572 default:
573 return new WP_REST_Response( [
574 'error' => 'Method not allowed'
575 ], 405 );
576 }
577 }
578
579 /**
580 * Handle POST requests for Streamable HTTP.
581 * This processes JSON-RPC requests and returns JSON responses.
582 */
583 private function handle_streamable_http_post( WP_REST_Request $request ) {
584 $this->release_session_lock();
585 $raw_body = $request->get_body();
586
587 if ( empty( $raw_body ) ) {
588 return new WP_REST_Response( [
589 'jsonrpc' => '2.0',
590 'id' => null,
591 'error' => [ 'code' => -32700, 'message' => 'Parse error: empty body' ]
592 ], 400 );
593 }
594
595 $data = json_decode( $raw_body, true );
596
597 if ( json_last_error() !== JSON_ERROR_NONE ) {
598 return new WP_REST_Response( [
599 'jsonrpc' => '2.0',
600 'id' => null,
601 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
602 ], 400 );
603 }
604
605 // Log the request if debugging is enabled
606 if ( $this->logging && isset( $data['method'] ) ) {
607 error_log( '[AI Engine MCP HTTP] ↓ ' . $data['method'] );
608 }
609
610 // Reuse the existing direct JSON-RPC handler
611 return $this->handle_direct_jsonrpc( $request, $data );
612 }
613
614 /**
615 * Handle GET requests for Streamable HTTP.
616 * This opens an SSE stream for server-to-client messages.
617 * Used when the server needs to send notifications or progress updates.
618 */
619 private function handle_streamable_http_get( WP_REST_Request $request ) {
620 // Check Accept header - must accept text/event-stream
621 $accept = $request->get_header( 'accept' );
622 if ( strpos( $accept, 'text/event-stream' ) === false ) {
623 return new WP_REST_Response( [
624 'error' => 'Accept header must include text/event-stream'
625 ], 406 );
626 }
627
628 // Get or create session ID
629 $session_header = $request->get_header( 'mcp-session-id' );
630 $session_id = !empty( $session_header ) ? sanitize_text_field( $session_header ) : wp_generate_uuid4();
631
632 if ( $this->logging ) {
633 error_log( '[AI Engine MCP HTTP] 📡 SSE stream opened for session: ' . substr( $session_id, 0, 8 ) . '...' );
634 }
635
636 // Set up SSE output
637 @ini_set( 'zlib.output_compression', '0' );
638 @ini_set( 'output_buffering', '0' );
639 @ini_set( 'implicit_flush', '1' );
640 if ( function_exists( 'ob_implicit_flush' ) ) {
641 ob_implicit_flush( true );
642 }
643
644 header( 'Content-Type: text/event-stream' );
645 header( 'Cache-Control: no-cache' );
646 header( 'X-Accel-Buffering: no' );
647 header( 'Connection: keep-alive' );
648 header( 'Mcp-Session-Id: ' . $session_id );
649
650 while ( ob_get_level() ) {
651 ob_end_flush();
652 }
653
654 $this->session_id = $session_id;
655 $this->last_action_time = time();
656
657 // Send initial connection event
658 echo "event: open\n";
659 echo 'data: {"session":"' . esc_js( $session_id ) . "\"}\n\n";
660 flush();
661
662 // Main SSE loop - listen for server-initiated messages
663 while ( true ) {
664 $max_time = $this->logging ? 30 : 60 * 3;
665 $idle = ( time() - $this->last_action_time ) >= $max_time;
666
667 if ( connection_aborted() || $idle ) {
668 if ( $this->logging ) {
669 error_log( '[AI Engine MCP HTTP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
670 }
671 break;
672 }
673
674 // Check for queued messages
675 foreach ( $this->fetch_messages( $session_id ) as $msg ) {
676 if ( isset( $msg['method'] ) && $msg['method'] === 'mwai/kill' ) {
677 echo "event: close\ndata: {}\n\n";
678 flush();
679 exit;
680 }
681
682 echo "event: message\n";
683 echo 'data: ' . wp_json_encode( $msg, JSON_UNESCAPED_UNICODE ) . "\n\n";
684 flush();
685 $this->last_action_time = time();
686 }
687
688 // Heartbeat every 10 seconds
689 $time_since_last = time() - $this->last_action_time;
690 if ( $time_since_last >= 10 && $time_since_last % 10 === 0 ) {
691 echo ": heartbeat\n\n";
692 flush();
693 }
694
695 usleep( 200000 ); // 200ms
696 }
697
698 exit;
699 }
700
701 /**
702 * Handle DELETE requests for Streamable HTTP.
703 * This terminates the session and cleans up any resources.
704 */
705 private function handle_streamable_http_delete( WP_REST_Request $request ) {
706 $session_header = $request->get_header( 'mcp-session-id' );
707
708 if ( empty( $session_header ) ) {
709 return new WP_REST_Response( [
710 'error' => 'Mcp-Session-Id header required'
711 ], 400 );
712 }
713
714 $session_id = sanitize_text_field( $session_header );
715
716 if ( $this->logging ) {
717 error_log( '[AI Engine MCP HTTP] 🗑️ Session terminated: ' . substr( $session_id, 0, 8 ) . '...' );
718 }
719
720 // Queue kill signal for any active SSE streams
721 $this->store_message( $session_id, [
722 'jsonrpc' => '2.0',
723 'method' => 'mwai/kill'
724 ] );
725
726 // Clean up any remaining transients for this session
727 global $wpdb;
728 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$session_id}_" ) . '%';
729 $wpdb->query(
730 $wpdb->prepare(
731 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
732 $like
733 )
734 );
735
736 // Return 204 No Content on successful termination
737 return new WP_REST_Response( null, 204 );
738 }
739 #endregion
740
741 #region Access Control
742 private function role_has_access( string $toolLevel ): bool {
743 if ( $this->mcp_role === 'admin' ) {
744 return true;
745 }
746 if ( $this->mcp_role === 'readwrite' ) {
747 return in_array( $toolLevel, [ 'read', 'write' ] );
748 }
749 if ( $this->mcp_role === 'readonly' ) {
750 return $toolLevel === 'read';
751 }
752 return false;
753 }
754 #endregion
755
756 #region Tools Definitions
757 private function get_tools_list() {
758 $base_tools = [
759 [
760 'name' => 'mcp_ping',
761 '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.',
762 'inputSchema' => [
763 'type' => 'object',
764 'properties' => (object) [],
765 'required' => []
766 ],
767 'annotations' => [
768 'readOnlyHint' => true,
769 'destructiveHint' => false,
770 'openWorldHint' => false,
771 ],
772 'accessLevel' => 'read',
773 ],
774 ];
775
776 if ( $this->logging ) {
777 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Starting with ' . count( $base_tools ) . ' base tools' );
778 }
779
780 $filtered_tools = apply_filters( 'mwai_mcp_tools', $base_tools );
781
782 if ( $this->logging ) {
783 error_log( '[AI Engine MCP] 🔧 get_tools_list() - After filters: ' . count( $filtered_tools ) . ' tools' );
784 }
785
786 // Build access level map for defense-in-depth checks in execute_tool()
787 foreach ( $filtered_tools as $tool ) {
788 if ( isset( $tool['name'] ) ) {
789 $this->tool_access_levels[ $tool['name'] ] = $tool['accessLevel'] ?? 'admin';
790 }
791 }
792
793 // Filter tools by access level based on the MCP role
794 if ( $this->mcp_role !== 'admin' ) {
795 $filtered_tools = array_filter( $filtered_tools, function ( $tool ) {
796 $level = $tool['accessLevel'] ?? 'admin';
797 return $this->role_has_access( $level );
798 } );
799 }
800
801 $normalized_tools = [];
802 foreach ( $filtered_tools as $tool_index => $tool_definition ) {
803 $normalized = $this->normalize_tool_definition( $tool_definition, $tool_index );
804 if ( $normalized ) {
805 $normalized_tools[] = $normalized;
806 }
807 }
808
809 if ( $this->logging ) {
810 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Normalized tools: ' . count( $normalized_tools ) );
811 }
812
813 return $normalized_tools;
814 }
815 #endregion
816
817 #region Resources Definitions
818 private function get_resources_list() {
819 return [];
820 }
821 #endregion
822
823 #region Prompts Definitions
824 private function get_prompts_list() {
825 return [];
826 }
827 #endregion
828
829 #region Tool Normalization Helpers
830 private function normalize_tool_definition( $tool, $index ) {
831 // NOTE: tool-registration warnings below are always emitted (no $this->logging
832 // gate). Each fires only when a tool is silently auto-fixed or auto-skipped at
833 // registration — exactly the case where the author needs to know. They're rare
834 // in normal operation and the only reliable diagnostic when something is off.
835 if ( !is_array( $tool ) ) {
836 error_log( '[AI Engine MCP] ⚠️ Tool definition at index ' . $index . ' skipped (expected array).' );
837 return null;
838 }
839
840 $name = isset( $tool['name'] ) ? trim( (string) $tool['name'] ) : '';
841 if ( $name === '' ) {
842 error_log( '[AI Engine MCP] ⚠️ Tool skipped due to missing name at index ' . $index );
843 return null;
844 }
845
846 $normalized_schema = $this->normalize_input_schema( $tool['inputSchema'] ?? null, $name );
847 if ( !$normalized_schema ) {
848 error_log( '[AI Engine MCP] ⚠️ Tool "' . $name . '" skipped due to invalid input schema.' );
849 return null;
850 }
851
852 $normalized = [
853 'name' => $name,
854 'inputSchema' => $normalized_schema,
855 ];
856
857 if ( isset( $tool['description'] ) && $tool['description'] !== '' ) {
858 $normalized['description'] = wp_strip_all_tags( (string) $tool['description'] );
859 }
860
861 if ( isset( $tool['annotations'] ) && is_array( $tool['annotations'] ) ) {
862 $annotations = $this->normalize_annotations( $tool['annotations'], $name );
863 if ( !empty( $annotations ) ) {
864 $normalized['annotations'] = $annotations;
865 }
866 }
867
868 return $normalized;
869 }
870
871 private function normalize_input_schema( $schema, string $tool_name ) {
872 if ( !is_array( $schema ) ) {
873 return null;
874 }
875
876 $type = isset( $schema['type'] ) ? (string) $schema['type'] : 'object';
877 if ( $type !== 'object' ) {
878 error_log( '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" has unsupported schema type: ' . $type );
879 return null;
880 }
881
882 $properties = [];
883 if ( isset( $schema['properties'] ) && ( is_array( $schema['properties'] ) || is_object( $schema['properties'] ) ) ) {
884 foreach ( (array) $schema['properties'] as $prop_name => $definition ) {
885 if ( !is_array( $definition ) ) {
886 $definition = [];
887 }
888
889 if ( isset( $definition['type'] ) ) {
890 // Validate type definition
891 if ( is_array( $definition['type'] ) ) {
892 // Array of types (union types) - validate they're compatible with MCP clients
893 $type_array = array_map( 'strval', $definition['type'] );
894
895 // Check for complex types that need additional schema details
896 $complex_types = array_intersect( $type_array, [ 'object', 'array' ] );
897 if ( !empty( $complex_types ) ) {
898 error_log(
899 '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" property "' . $prop_name .
900 '" has problematic union type with complex types: [' . implode( ', ', $type_array ) .
901 ']. This breaks ChatGPT. Auto-fixing by removing type constraint.'
902 );
903 // Auto-fix: Remove the type constraint to accept any value
904 unset( $definition['type'] );
905 // Keep description if present, or add one
906 if ( !isset( $definition['description'] ) ) {
907 $definition['description'] = 'Value can be of any type';
908 }
909 }
910 else {
911 $definition['type'] = $type_array;
912 }
913 }
914 else {
915 $definition['type'] = (string) $definition['type'];
916 }
917 }
918
919 $properties[ $prop_name ] = $definition;
920 }
921 }
922
923 $required = [];
924 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
925 foreach ( $schema['required'] as $field ) {
926 $field_name = trim( (string) $field );
927 if ( $field_name !== '' ) {
928 $required[] = $field_name;
929 }
930 }
931 $required = array_values( array_unique( $required ) );
932 }
933
934 $normalized = [
935 'type' => 'object',
936 'properties' => empty( $properties ) ? new stdClass() : $properties,
937 ];
938
939 if ( !empty( $required ) ) {
940 $normalized['required'] = $required;
941 }
942
943 if ( array_key_exists( 'additionalProperties', $schema ) ) {
944 $normalized['additionalProperties'] = (bool) $schema['additionalProperties'];
945 }
946
947 return $normalized;
948 }
949
950 private function normalize_annotations( array $annotations, string $tool_name ): array {
951 $allowed_keys = [ 'title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ];
952 $normalized = [];
953
954 foreach ( $annotations as $key => $value ) {
955 if ( !in_array( $key, $allowed_keys, true ) ) {
956 continue;
957 }
958
959 if ( in_array( $key, [ 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ], true ) ) {
960 $normalized[ $key ] = (bool) $value;
961 }
962 elseif ( $key === 'title' ) {
963 $normalized['title'] = wp_strip_all_tags( (string) $value );
964 }
965 }
966
967 if ( empty( $normalized ) && $this->logging && !empty( $annotations ) ) {
968 error_log( '[AI Engine MCP] 🔎 Tool "' . $tool_name . '" included unsupported annotation keys.' );
969 }
970
971 return $normalized;
972 }
973 #endregion
974
975 #region Tools Call (execute_tool)
976 private function execute_tool( $tool, $args, $id ) {
977 $start = microtime( true );
978 $response = null;
979 $status = 'error';
980 $error_msg = null;
981 try {
982 // Ensure tool access levels are populated (each HTTP request starts fresh)
983 if ( empty( $this->tool_access_levels ) ) {
984 $this->get_tools_list();
985 }
986
987 // Defense in depth: verify tool access even if it wasn't filtered from the listing
988 $tool_level = $this->tool_access_levels[ $tool ] ?? 'admin';
989 if ( !$this->role_has_access( $tool_level ) ) {
990 $error_msg = "Access denied: tool '{$tool}' requires '{$tool_level}' access.";
991 $response = $this->rpc_error( $id, -32600, $error_msg );
992 return $response;
993 }
994
995 // Handle built-in tools first
996 if ( $tool === 'mcp_ping' ) {
997 if ( $this->logging ) {
998 $this->log( '🛠️ Tool: mcp_ping' );
999 }
1000 $ping_data = [
1001 'time' => gmdate( 'Y-m-d H:i:s' ),
1002 'name' => get_bloginfo( 'name' ),
1003 ];
1004 $response = [
1005 'jsonrpc' => '2.0',
1006 'id' => $id,
1007 'result' => [
1008 'content' => [
1009 [
1010 'type' => 'text',
1011 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
1012 ],
1013 ],
1014 'data' => $ping_data,
1015 ],
1016 ];
1017 $status = 'success';
1018 return $response;
1019 }
1020
1021 // Let other modules handle their tools
1022 if ( $this->logging ) {
1023 // Log tool calls with more context
1024 $args_preview = '';
1025 if ( !empty( $args ) ) {
1026 // Show key args for common tools
1027 if ( isset( $args['ID'] ) ) {
1028 $args_preview = ' (ID: ' . $args['ID'] . ')';
1029 }
1030 elseif ( isset( $args['query'] ) ) {
1031 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
1032 }
1033 elseif ( isset( $args['message'] ) ) {
1034 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
1035 }
1036 }
1037 // Log to both error log and UI
1038 error_log( '[AI Engine MCP] 🛠️ ' . $tool . $args_preview );
1039 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
1040 }
1041 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
1042
1043 if ( $filtered !== null ) {
1044 // Check if it's already a full JSON-RPC response (backward compatibility)
1045 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
1046 $response = $filtered;
1047 $status = isset( $filtered['error'] ) ? 'error' : 'success';
1048 if ( $status === 'error' ) {
1049 $error_msg = $filtered['error']['message'] ?? null;
1050 }
1051 return $response;
1052 }
1053
1054 // Otherwise, wrap the result in proper JSON-RPC format
1055 $response = [
1056 'jsonrpc' => '2.0',
1057 'id' => $id,
1058 'result' => $this->format_tool_result( $filtered ),
1059 ];
1060 $status = 'success';
1061 return $response;
1062 }
1063
1064 throw new Exception( "Unknown tool: {$tool}" );
1065 }
1066 catch ( Exception $e ) {
1067 $error_msg = $e->getMessage();
1068 $response = $this->rpc_error( $id, -32603, $error_msg );
1069 return $response;
1070 }
1071 finally {
1072 $duration_ms = (int) round( ( microtime( true ) - $start ) * 1000 );
1073 // Fire the action even on access denials and errors so admins can see
1074 // attempted-but-blocked tool calls in MCP Logs.
1075 do_action( 'mwai_mcp_tool_called', [
1076 'tool' => $tool,
1077 'args' => $args,
1078 'result' => $response,
1079 'status' => $status,
1080 'error_msg' => $error_msg,
1081 'duration_ms' => $duration_ms,
1082 'client_id' => $this->auth_client_id,
1083 'client_name' => $this->auth_client_name,
1084 'auth_method' => $this->auth_method,
1085 'request_id' => $id,
1086 'user_id' => get_current_user_id(),
1087 ] );
1088 }
1089 }
1090 #endregion
1091
1092 #region Handle /upload (one-time file upload via token)
1093 public function handle_upload( WP_REST_Request $request ) {
1094 $token = $request->get_param( 'token' );
1095 if ( empty( $token ) ) {
1096 return new WP_REST_Response( [ 'success' => false, 'message' => 'Missing token.' ], 400 );
1097 }
1098
1099 $transient_key = 'mwai_mcp_upload_' . $token;
1100 $data = get_transient( $transient_key );
1101 if ( empty( $data ) ) {
1102 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid or expired upload token.' ], 403 );
1103 }
1104
1105 // Immediately delete the transient so the token can only be used once
1106 delete_transient( $transient_key );
1107
1108 $files = $request->get_file_params();
1109 if ( empty( $files['file'] ) ) {
1110 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided. Use: curl -X POST -F "file=@/path/to/file" "<url>"' ], 400 );
1111 }
1112
1113 $uploaded = $files['file'];
1114 if ( $uploaded['error'] !== UPLOAD_ERR_OK ) {
1115 return new WP_REST_Response( [ 'success' => false, 'message' => 'Upload error code: ' . $uploaded['error'] ], 400 );
1116 }
1117
1118 // Set admin context for media handling
1119 if ( !current_user_can( 'administrator' ) ) {
1120 wp_set_current_user( 1 );
1121 }
1122
1123 require_once ABSPATH . 'wp-admin/includes/file.php';
1124 require_once ABSPATH . 'wp-admin/includes/media.php';
1125 require_once ABSPATH . 'wp-admin/includes/image.php';
1126
1127 // Use the filename from the transient (sanitized at creation time)
1128 $file = [
1129 'name' => $data['filename'],
1130 'tmp_name' => $uploaded['tmp_name'],
1131 ];
1132
1133 $attachment_id = media_handle_sideload( $file, 0, $data['description'] );
1134 if ( is_wp_error( $attachment_id ) ) {
1135 return new WP_REST_Response( [ 'success' => false, 'message' => $attachment_id->get_error_message() ], 500 );
1136 }
1137
1138 if ( !empty( $data['title'] ) ) {
1139 wp_update_post( [ 'ID' => $attachment_id, 'post_title' => sanitize_text_field( $data['title'] ) ] );
1140 }
1141 if ( !empty( $data['alt'] ) ) {
1142 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $data['alt'] ) );
1143 }
1144
1145 return new WP_REST_Response( [
1146 'success' => true,
1147 'attachment_id' => $attachment_id,
1148 'url' => wp_get_attachment_url( $attachment_id ),
1149 ], 200 );
1150 }
1151 #endregion
1152
1153 #region Message Queue (per-message transient)
1154 private function transient_key( $sess, $id ) {
1155 return "{$this->queue_key}_{$sess}_{$id}";
1156 }
1157
1158 private function store_message( $sess, $payload ) {
1159 if ( !$sess ) {
1160 return;
1161 }
1162 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
1163 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
1164 $this->log( "queued #{$idKey}" );
1165 }
1166
1167 private function fetch_messages( $sess ) {
1168 global $wpdb;
1169 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
1170
1171 $rows = $wpdb->get_results(
1172 $wpdb->prepare(
1173 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
1174 $like
1175 ),
1176 ARRAY_A
1177 );
1178
1179 $msgs = [];
1180 foreach ( $rows as $r ) {
1181 $msgs[] = maybe_unserialize( $r['option_value'] );
1182 delete_option( $r['option_name'] );
1183 }
1184 usort( $msgs, fn ( $a, $b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
1185 if ( $msgs ) {
1186 $this->log( 'flush ' . count( $msgs ) . ' msg(s)' );
1187 }
1188 return $msgs;
1189 }
1190 #endregion
1191
1192 #region Resources (note)
1193 /*--------------------------------------------------*/
1194 /**
1195 * MCP also supports “resources” – static or dynamic data a client can
1196 * retrieve by URL (e.g. `mcp://resource/posts/123`).
1197 */
1198 #endregion
1199 }
1200