PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
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 6 days ago mcp-oauth.php 1 month ago mcp-rest.php 1 week ago mcp.conf 1 year ago mcp.js 8 months ago mcp.md 8 months ago mcp.php 23 hours ago wpai-connectors.php 1 month ago wpai-gateway-availability.php 2 months ago wpai-gateway-directory.php 2 months ago wpai-gateway-image-model.php 2 months ago wpai-gateway-model.php 2 months ago wpai-gateway-providers.php 2 months ago wpai-gateway.php 2 months ago
mcp.php
1206 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. The token is high-entropy (wp_generate_password),
116 // compared with hash_equals, and the route is hidden (show_in_index=false).
117 // Kept because Claude Code and other MCP connectors currently work more
118 // reliably this way when proxies strip the Authorization header.
119 // TODO: Re-evaluate after 2026-12-27. Check whether connectors still need
120 // the URL-token fallback, or if header/OAuth auth has become reliable enough
121 // to deprecate it (flagged by WP.org automated security review, Jun 2026).
122 if ( !empty( $this->bearer_token ) ) {
123 register_rest_route( $this->namespace, '/' . $this->bearer_token, [
124 'methods' => [ 'GET', 'POST', 'DELETE' ],
125 'callback' => [ $this, 'handle_streamable_http' ],
126 'permission_callback' => function ( $request ) {
127 return $this->handle_noauth_access_streamable( $request );
128 },
129 'show_in_index' => false,
130 ] );
131 }
132
133 // File upload endpoint for wp_upload_request
134 // Uses a one-time token in the URL for authentication (no bearer header needed from curl)
135 register_rest_route( $this->namespace, '/upload/(?P<token>[a-zA-Z0-9]+)', [
136 'methods' => 'POST',
137 'callback' => [ $this, 'handle_upload' ],
138 'permission_callback' => '__return_true',
139 'show_in_index' => false,
140 ] );
141 }
142 #endregion
143
144 #region Auth (Bearer token)
145 /**
146 * SECURITY: MCP provides powerful WordPress management capabilities, so access must be strictly controlled.
147 *
148 * By default, only administrators can access MCP endpoints. This prevents lower-privileged users
149 * (subscribers, contributors, etc.) from executing dangerous operations like creating admin users,
150 * deleting content, or modifying settings.
151 *
152 * When a bearer token is configured, it overrides the default admin check, but access is DENIED
153 * unless a valid token is provided. This ensures MCP is secure even with default settings.
154 */
155 public function can_access_mcp( $request ) {
156 // Default to requiring administrator capability for security
157 $is_admin = current_user_can( 'administrator' );
158 return apply_filters( 'mwai_allow_mcp', $is_admin, $request );
159 }
160
161 public function auth_via_bearer_token( $allow, $request ) {
162 // Skip if already authenticated as admin
163 if ( $allow ) {
164 return $allow;
165 }
166
167 $hdr = $request->get_header( 'authorization' );
168
169 // If no authorization header but bearer token is configured, deny access
170 if ( !$hdr && !empty( $this->bearer_token ) ) {
171 if ( $this->logging ) {
172 error_log( '[AI Engine MCP] ❌ No authorization header provided. Server may be stripping headers.' );
173 }
174 return false;
175 }
176
177 // Check for Bearer token in header
178 if ( $hdr && preg_match( '/Bearer\s+(.+)/i', $hdr, $m ) ) {
179 $token = trim( $m[1] );
180 $auth_result = 'none';
181
182 // Check if it's an OAuth token
183 if ( $this->oauth ) {
184 $token_data = $this->oauth->validate_token( $token );
185 if ( $token_data ) {
186 // Defense in depth: even if a token was issued (or stored from before
187 // the authorize-time admin gate landed), only accept it if the linked
188 // user still holds administrator capability. Otherwise a Subscriber's
189 // OAuth token would inherit the global mcp_role and reach admin tools.
190 if ( !$this->oauth->user_can_authorize( $token_data['user_id'] ) ) {
191 if ( $this->logging ) {
192 error_log( '[AI Engine MCP] ❌ OAuth token rejected: user ' . $token_data['user_id'] . ' is not an administrator.' );
193 }
194 return false;
195 }
196 // Set current user based on OAuth token
197 wp_set_current_user( $token_data['user_id'] );
198 $auth_result = 'oauth';
199 $this->auth_method = 'oauth';
200 $this->auth_client_id = $token_data['client_id'] ?? null;
201 $this->auth_client_name = $token_data['client_name'] ?? null;
202 return true;
203 }
204 }
205
206 // Fall back to static bearer token if configured
207 if ( !empty( $this->bearer_token ) && hash_equals( $this->bearer_token, $token ) ) {
208 if ( $admin = $this->core->get_admin_user() ) {
209 wp_set_current_user( $admin->ID, $admin->user_login );
210 }
211 $auth_result = 'static';
212 $this->auth_method = 'bearer';
213 $this->auth_client_id = 'bearer';
214 $this->auth_client_name = null;
215 if ( $this->logging ) {
216 error_log( '[AI Engine MCP] 🔐 Bearer token auth OK' );
217 }
218 return true;
219 }
220
221 if ( $this->logging && $auth_result === 'none' ) {
222 error_log( '[AI Engine MCP] ❌ Bearer token invalid.' );
223 }
224 // Explicitly deny access for invalid tokens
225 return false;
226 }
227
228 // ?token=xyz fallback (optional) - only for static bearer token
229 if ( !empty( $this->bearer_token ) ) {
230 $q = sanitize_text_field( $request->get_param( 'token' ) );
231 if ( $q && hash_equals( $this->bearer_token, $q ) ) {
232 if ( $admin = $this->core->get_admin_user() ) {
233 wp_set_current_user( $admin->ID, $admin->user_login );
234 }
235 $this->auth_method = 'bearer';
236 $this->auth_client_id = 'bearer';
237 return true;
238 }
239 }
240
241 // If bearer token is configured but no valid auth provided, deny access
242 if ( !empty( $this->bearer_token ) ) {
243 return false;
244 }
245
246 return $allow;
247 }
248
249 public function handle_noauth_access_streamable( $request ) {
250 // For Streamable HTTP with token in URL path (no trailing slash)
251 $route = $request->get_route();
252 $expected = '/' . $this->namespace . '/' . $this->bearer_token;
253 if ( $route !== $expected ) {
254 if ( $this->logging ) {
255 error_log( '[AI Engine MCP] ❌ Invalid Streamable HTTP no-auth URL access attempt.' );
256 }
257 return false;
258 }
259
260 // Set the current user to admin since token is valid
261 if ( $admin = $this->core->get_admin_user() ) {
262 wp_set_current_user( $admin->ID, $admin->user_login );
263 }
264 $this->auth_method = 'bearer';
265 $this->auth_client_id = 'bearer';
266 return true;
267 }
268
269 #endregion
270
271 #region Helpers (log / JSON-RPC utils)
272 /**
273 * Release the PHP session lock as early as possible. Long MCP calls (e.g. content
274 * mutations on large posts) can otherwise serialize behind any other request from the
275 * same client that opened a session, since PHP holds an exclusive write lock on the
276 * session file for the lifetime of the request. The result is the ~max_execution_time
277 * hangs operators see on busy sites. Closing the session is idempotent and safe — if
278 * no session is active the call is a no-op.
279 */
280 private function release_session_lock(): void {
281 if ( function_exists( 'session_status' ) && session_status() === PHP_SESSION_ACTIVE ) {
282 session_write_close();
283 }
284 }
285
286 private function log( $msg ) {
287 // This method is for internal UI logs - keep it minimal
288 if ( $this->logging ) {
289 // Only log important messages to UI
290 if ( strpos( $msg, 'queued' ) === false && strpos( $msg, 'flush' ) === false ) {
291 Meow_MWAI_Logging::log( "[AI Engine MCP] {$msg}" );
292 }
293 }
294 }
295
296 /** Wrap a JSON-RPC error object */
297 private function rpc_error( $id, int $code, string $msg, $extra = null ): array {
298 $err = [ 'code' => $code, 'message' => $msg ];
299 if ( $extra !== null ) {
300 $err['data'] = $extra;
301 }
302 return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ];
303 }
304
305 /** Format tool result for MCP protocol */
306 private function format_tool_result( $result ): array {
307 // If result is a string, wrap it in the MCP content format
308 if ( is_string( $result ) ) {
309 return [
310 'content' => [
311 [
312 'type' => 'text',
313 'text' => $result,
314 ],
315 ],
316 ];
317 }
318
319 // If result has 'content' key, assume it's already properly formatted
320 if ( is_array( $result ) && isset( $result['content'] ) ) {
321 return $result;
322 }
323
324 // If result is an array without 'content' key, wrap it as JSON
325 if ( is_array( $result ) ) {
326 return [
327 'content' => [
328 [
329 'type' => 'text',
330 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT ),
331 ],
332 ],
333 'data' => $result,
334 ];
335 }
336
337 // For any other type, convert to string and wrap
338 return [
339 'content' => [
340 [
341 'type' => 'text',
342 'text' => (string) $result,
343 ],
344 ],
345 ];
346 }
347 #endregion
348
349 #region Handle direct JSON-RPC
350 /**
351 * Shared JSON-RPC processor: takes a decoded request body, dispatches the method,
352 * and returns an immediate WP_REST_Response. Used by the Streamable HTTP POST handler
353 * (the modern transport for Claude Desktop, Claude.ai, ChatGPT, Claude Code).
354 */
355 private function handle_direct_jsonrpc( WP_REST_Request $request, $data ) {
356 $this->release_session_lock();
357 $id = $data['id'] ?? null;
358 $method = $data['method'] ?? null;
359
360 if ( json_last_error() !== JSON_ERROR_NONE ) {
361 $response = new WP_REST_Response( [
362 'jsonrpc' => '2.0',
363 'id' => null,
364 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
365 ], 200 );
366 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
367 $session_header = $request->get_header( 'mcp-session-id' );
368 if ( !empty( $session_header ) ) {
369 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
370 }
371 return $response;
372 }
373
374 if ( !is_array( $data ) || !$method ) {
375 $response = new WP_REST_Response( [
376 'jsonrpc' => '2.0',
377 'id' => $id,
378 'error' => [ 'code' => -32600, 'message' => 'Invalid Request' ]
379 ], 200 );
380 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
381 $session_header = $request->get_header( 'mcp-session-id' );
382 if ( !empty( $session_header ) ) {
383 return $this->attach_session_header( $response, sanitize_text_field( $session_header ) );
384 }
385 return $response;
386 }
387
388 $session_header = $request->get_header( 'mcp-session-id' );
389 $session_id = '';
390 if ( !empty( $session_header ) ) {
391 $session_id = sanitize_text_field( $session_header );
392 }
393
394 if ( $method === 'initialize' || empty( $session_id ) ) {
395 $session_id = wp_generate_uuid4();
396 if ( $this->logging ) {
397 error_log( '[AI Engine MCP] 🆔 Direct session initialized: ' . $session_id );
398 }
399 }
400
401 try {
402 $reply = null;
403
404 switch ( $method ) {
405 case 'initialize':
406 // Check if client requests a specific protocol version
407 $params = $data['params'] ?? [];
408 $requested_version = $params['protocolVersion'] ?? null;
409 $client_info = $params['clientInfo'] ?? null;
410
411 if ( $this->logging && $client_info ) {
412 $client_name = $client_info['name'] ?? 'unknown';
413 $client_version = $client_info['version'] ?? 'unknown';
414 error_log( "[AI Engine MCP] Client: {$client_name} v{$client_version}" );
415 }
416
417 // Negotiate protocol version: use client's version if supported
418 $negotiated_version = $this->protocol_version;
419 if ( $requested_version && in_array( $requested_version, $this->supported_protocol_versions, true ) ) {
420 $negotiated_version = $requested_version;
421 }
422 else if ( $requested_version && $requested_version !== $this->protocol_version ) {
423 if ( $this->logging ) {
424 Meow_MWAI_Logging::warn( "[AI Engine MCP] Client requested unsupported protocol version {$requested_version}" );
425 }
426 }
427
428 $reply = [
429 'jsonrpc' => '2.0',
430 'id' => $id,
431 'result' => [
432 'protocolVersion' => $negotiated_version,
433 'serverInfo' => (object) [
434 'name' => 'AI Engine - ' . get_bloginfo( 'name' ),
435 'version' => $this->server_version,
436 ],
437 'capabilities' => (object) [
438 'tools' => new stdClass(),
439 ],
440 ],
441 ];
442 break;
443
444 case 'tools/list':
445 $tools = $this->get_tools_list();
446
447 // Debug logging for tools/list
448 if ( $this->logging ) {
449 $user_agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
450 error_log( '[AI Engine MCP Direct] 📋 tools/list requested by: ' . $user_agent );
451 error_log( '[AI Engine MCP Direct] 📊 Returning ' . count( $tools ) . ' tools' );
452 if ( count( $tools ) > 0 ) {
453 $tool_names = array_column( $tools, 'name' );
454 error_log( '[AI Engine MCP Direct] 🛠️ Tool names: ' . implode( ', ', $tool_names ) );
455 }
456 else {
457 error_log( '[AI Engine MCP Direct] ⚠️ WARNING: No tools returned!' );
458 }
459 }
460
461 $reply = [
462 'jsonrpc' => '2.0',
463 'id' => $id,
464 'result' => [ 'tools' => $tools ],
465 ];
466 break;
467
468 case 'tools/call':
469 $params = $data['params'] ?? [];
470 $tool = $params['name'] ?? '';
471 $arguments = $params['arguments'] ?? [];
472
473 if ( $this->logging ) {
474 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Tool: ' . $tool );
475 error_log( '[AI Engine MCP Direct] 🔧 tools/call - Arguments: ' . wp_json_encode( $arguments ) );
476 }
477
478 try {
479 $reply = $this->execute_tool( $tool, $arguments, $id );
480 if ( $this->logging ) {
481 error_log( '[AI Engine MCP Direct] �
482 tools/call - Success for tool: ' . $tool );
483 }
484 }
485 catch ( Exception $e ) {
486 if ( $this->logging ) {
487 error_log( '[AI Engine MCP Direct] tools/call - Error: ' . $e->getMessage() );
488 }
489 throw $e;
490 }
491 break;
492
493 case 'notifications/initialized':
494 // This is a notification from the client indicating it has initialized
495 // No response needed for notifications
496 // Client initialized - no need to log
497 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
498 break;
499
500 default:
501 // Check if it's a notification (no id)
502 if ( $id === null && strpos( $method, 'notifications/' ) === 0 ) {
503 if ( $this->logging ) {
504 error_log( '[AI Engine MCP] 📨 Notification received: ' . $method );
505 }
506 return $this->attach_session_header( new WP_REST_Response( null, 204 ), $session_id );
507 }
508
509 $reply = [
510 'jsonrpc' => '2.0',
511 'id' => $id,
512 'error' => [ 'code' => -32601, 'message' => "Method not found: {$method}" ]
513 ];
514 }
515
516 // Ensure proper JSON-RPC response
517 $response = new WP_REST_Response( $reply, 200 );
518 $response->set_headers( [ 'Content-Type' => 'application/json' ] );
519 return $this->attach_session_header( $response, $session_id );
520
521 }
522 catch ( Exception $e ) {
523 if ( $this->logging ) {
524 error_log( '[AI Engine MCP] ❌ Exception in handle_direct_jsonrpc: ' . $e->getMessage() );
525 }
526
527 $error_response = new WP_REST_Response( [
528 'jsonrpc' => '2.0',
529 'id' => $id,
530 'error' => [ 'code' => -32603, 'message' => 'Internal error', 'data' => $e->getMessage() ]
531 ], 200 );
532 $error_response->set_headers( [ 'Content-Type' => 'application/json' ] );
533 return $this->attach_session_header( $error_response, $session_id );
534 }
535 }
536 #endregion
537
538 #region Session helpers
539 private function attach_session_header( WP_REST_Response $response, string $session_id ) {
540 if ( empty( $session_id ) ) {
541 return $response;
542 }
543
544 $response->header( 'Mcp-Session-Id', $session_id );
545
546 if ( $this->logging ) {
547 error_log( '[AI Engine MCP] 🪪 Response session header: ' . $session_id );
548 }
549
550 return $response;
551 }
552 #endregion
553
554 #region Handle Streamable HTTP (Modern MCP transport)
555 /**
556 * Handle Streamable HTTP requests per MCP specification.
557 * This is the modern transport used by Claude Code and other MCP clients.
558 *
559 * - POST: Send JSON-RPC request, receive JSON response (or SSE for streaming)
560 * - GET: Open SSE stream for server-initiated messages
561 * - DELETE: Terminate the session
562 *
563 * @see https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http
564 */
565 public function handle_streamable_http( WP_REST_Request $request ) {
566 $method = $request->get_method();
567
568 switch ( $method ) {
569 case 'POST':
570 return $this->handle_streamable_http_post( $request );
571
572 case 'GET':
573 return $this->handle_streamable_http_get( $request );
574
575 case 'DELETE':
576 return $this->handle_streamable_http_delete( $request );
577
578 default:
579 return new WP_REST_Response( [
580 'error' => 'Method not allowed'
581 ], 405 );
582 }
583 }
584
585 /**
586 * Handle POST requests for Streamable HTTP.
587 * This processes JSON-RPC requests and returns JSON responses.
588 */
589 private function handle_streamable_http_post( WP_REST_Request $request ) {
590 $this->release_session_lock();
591 $raw_body = $request->get_body();
592
593 if ( empty( $raw_body ) ) {
594 return new WP_REST_Response( [
595 'jsonrpc' => '2.0',
596 'id' => null,
597 'error' => [ 'code' => -32700, 'message' => 'Parse error: empty body' ]
598 ], 400 );
599 }
600
601 $data = json_decode( $raw_body, true );
602
603 if ( json_last_error() !== JSON_ERROR_NONE ) {
604 return new WP_REST_Response( [
605 'jsonrpc' => '2.0',
606 'id' => null,
607 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ]
608 ], 400 );
609 }
610
611 // Log the request if debugging is enabled
612 if ( $this->logging && isset( $data['method'] ) ) {
613 error_log( '[AI Engine MCP HTTP] ↓ ' . $data['method'] );
614 }
615
616 // Reuse the existing direct JSON-RPC handler
617 return $this->handle_direct_jsonrpc( $request, $data );
618 }
619
620 /**
621 * Handle GET requests for Streamable HTTP.
622 * This opens an SSE stream for server-to-client messages.
623 * Used when the server needs to send notifications or progress updates.
624 */
625 private function handle_streamable_http_get( WP_REST_Request $request ) {
626 // Check Accept header - must accept text/event-stream
627 $accept = $request->get_header( 'accept' );
628 if ( strpos( $accept, 'text/event-stream' ) === false ) {
629 return new WP_REST_Response( [
630 'error' => 'Accept header must include text/event-stream'
631 ], 406 );
632 }
633
634 // Get or create session ID
635 $session_header = $request->get_header( 'mcp-session-id' );
636 $session_id = !empty( $session_header ) ? sanitize_text_field( $session_header ) : wp_generate_uuid4();
637
638 if ( $this->logging ) {
639 error_log( '[AI Engine MCP HTTP] 📡 SSE stream opened for session: ' . substr( $session_id, 0, 8 ) . '...' );
640 }
641
642 // Set up SSE output
643 @ini_set( 'zlib.output_compression', '0' );
644 @ini_set( 'output_buffering', '0' );
645 @ini_set( 'implicit_flush', '1' );
646 if ( function_exists( 'ob_implicit_flush' ) ) {
647 ob_implicit_flush( true );
648 }
649
650 header( 'Content-Type: text/event-stream' );
651 header( 'Cache-Control: no-cache' );
652 header( 'X-Accel-Buffering: no' );
653 header( 'Connection: keep-alive' );
654 header( 'Mcp-Session-Id: ' . $session_id );
655
656 while ( ob_get_level() ) {
657 ob_end_flush();
658 }
659
660 $this->session_id = $session_id;
661 $this->last_action_time = time();
662
663 // Send initial connection event
664 echo "event: open\n";
665 echo 'data: {"session":"' . esc_js( $session_id ) . "\"}\n\n";
666 flush();
667
668 // Main SSE loop - listen for server-initiated messages
669 while ( true ) {
670 $max_time = $this->logging ? 30 : 60 * 3;
671 $idle = ( time() - $this->last_action_time ) >= $max_time;
672
673 if ( connection_aborted() || $idle ) {
674 if ( $this->logging ) {
675 error_log( '[AI Engine MCP HTTP] 🔚 SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')' );
676 }
677 break;
678 }
679
680 // Check for queued messages
681 foreach ( $this->fetch_messages( $session_id ) as $msg ) {
682 if ( isset( $msg['method'] ) && $msg['method'] === 'mwai/kill' ) {
683 echo "event: close\ndata: {}\n\n";
684 flush();
685 exit;
686 }
687
688 echo "event: message\n";
689 echo 'data: ' . wp_json_encode( $msg, JSON_UNESCAPED_UNICODE ) . "\n\n";
690 flush();
691 $this->last_action_time = time();
692 }
693
694 // Heartbeat every 10 seconds
695 $time_since_last = time() - $this->last_action_time;
696 if ( $time_since_last >= 10 && $time_since_last % 10 === 0 ) {
697 echo ": heartbeat\n\n";
698 flush();
699 }
700
701 usleep( 200000 ); // 200ms
702 }
703
704 exit;
705 }
706
707 /**
708 * Handle DELETE requests for Streamable HTTP.
709 * This terminates the session and cleans up any resources.
710 */
711 private function handle_streamable_http_delete( WP_REST_Request $request ) {
712 $session_header = $request->get_header( 'mcp-session-id' );
713
714 if ( empty( $session_header ) ) {
715 return new WP_REST_Response( [
716 'error' => 'Mcp-Session-Id header required'
717 ], 400 );
718 }
719
720 $session_id = sanitize_text_field( $session_header );
721
722 if ( $this->logging ) {
723 error_log( '[AI Engine MCP HTTP] 🗑️ Session terminated: ' . substr( $session_id, 0, 8 ) . '...' );
724 }
725
726 // Queue kill signal for any active SSE streams
727 $this->store_message( $session_id, [
728 'jsonrpc' => '2.0',
729 'method' => 'mwai/kill'
730 ] );
731
732 // Clean up any remaining transients for this session
733 global $wpdb;
734 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$session_id}_" ) . '%';
735 $wpdb->query(
736 $wpdb->prepare(
737 "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
738 $like
739 )
740 );
741
742 // Return 204 No Content on successful termination
743 return new WP_REST_Response( null, 204 );
744 }
745 #endregion
746
747 #region Access Control
748 private function role_has_access( string $toolLevel ): bool {
749 if ( $this->mcp_role === 'admin' ) {
750 return true;
751 }
752 if ( $this->mcp_role === 'readwrite' ) {
753 return in_array( $toolLevel, [ 'read', 'write' ] );
754 }
755 if ( $this->mcp_role === 'readonly' ) {
756 return $toolLevel === 'read';
757 }
758 return false;
759 }
760 #endregion
761
762 #region Tools Definitions
763 private function get_tools_list() {
764 $base_tools = [
765 [
766 'name' => 'mcp_ping',
767 '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.',
768 'inputSchema' => [
769 'type' => 'object',
770 'properties' => (object) [],
771 'required' => []
772 ],
773 'annotations' => [
774 'readOnlyHint' => true,
775 'destructiveHint' => false,
776 'openWorldHint' => false,
777 ],
778 'accessLevel' => 'read',
779 ],
780 ];
781
782 if ( $this->logging ) {
783 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Starting with ' . count( $base_tools ) . ' base tools' );
784 }
785
786 $filtered_tools = apply_filters( 'mwai_mcp_tools', $base_tools );
787
788 if ( $this->logging ) {
789 error_log( '[AI Engine MCP] 🔧 get_tools_list() - After filters: ' . count( $filtered_tools ) . ' tools' );
790 }
791
792 // Build access level map for defense-in-depth checks in execute_tool()
793 foreach ( $filtered_tools as $tool ) {
794 if ( isset( $tool['name'] ) ) {
795 $this->tool_access_levels[ $tool['name'] ] = $tool['accessLevel'] ?? 'admin';
796 }
797 }
798
799 // Filter tools by access level based on the MCP role
800 if ( $this->mcp_role !== 'admin' ) {
801 $filtered_tools = array_filter( $filtered_tools, function ( $tool ) {
802 $level = $tool['accessLevel'] ?? 'admin';
803 return $this->role_has_access( $level );
804 } );
805 }
806
807 $normalized_tools = [];
808 foreach ( $filtered_tools as $tool_index => $tool_definition ) {
809 $normalized = $this->normalize_tool_definition( $tool_definition, $tool_index );
810 if ( $normalized ) {
811 $normalized_tools[] = $normalized;
812 }
813 }
814
815 if ( $this->logging ) {
816 error_log( '[AI Engine MCP] 🔧 get_tools_list() - Normalized tools: ' . count( $normalized_tools ) );
817 }
818
819 return $normalized_tools;
820 }
821 #endregion
822
823 #region Resources Definitions
824 private function get_resources_list() {
825 return [];
826 }
827 #endregion
828
829 #region Prompts Definitions
830 private function get_prompts_list() {
831 return [];
832 }
833 #endregion
834
835 #region Tool Normalization Helpers
836 private function normalize_tool_definition( $tool, $index ) {
837 // NOTE: tool-registration warnings below are always emitted (no $this->logging
838 // gate). Each fires only when a tool is silently auto-fixed or auto-skipped at
839 // registration — exactly the case where the author needs to know. They're rare
840 // in normal operation and the only reliable diagnostic when something is off.
841 if ( !is_array( $tool ) ) {
842 error_log( '[AI Engine MCP] ⚠️ Tool definition at index ' . $index . ' skipped (expected array).' );
843 return null;
844 }
845
846 $name = isset( $tool['name'] ) ? trim( (string) $tool['name'] ) : '';
847 if ( $name === '' ) {
848 error_log( '[AI Engine MCP] ⚠️ Tool skipped due to missing name at index ' . $index );
849 return null;
850 }
851
852 $normalized_schema = $this->normalize_input_schema( $tool['inputSchema'] ?? null, $name );
853 if ( !$normalized_schema ) {
854 error_log( '[AI Engine MCP] ⚠️ Tool "' . $name . '" skipped due to invalid input schema.' );
855 return null;
856 }
857
858 $normalized = [
859 'name' => $name,
860 'inputSchema' => $normalized_schema,
861 ];
862
863 if ( isset( $tool['description'] ) && $tool['description'] !== '' ) {
864 $normalized['description'] = wp_strip_all_tags( (string) $tool['description'] );
865 }
866
867 if ( isset( $tool['annotations'] ) && is_array( $tool['annotations'] ) ) {
868 $annotations = $this->normalize_annotations( $tool['annotations'], $name );
869 if ( !empty( $annotations ) ) {
870 $normalized['annotations'] = $annotations;
871 }
872 }
873
874 return $normalized;
875 }
876
877 private function normalize_input_schema( $schema, string $tool_name ) {
878 if ( !is_array( $schema ) ) {
879 return null;
880 }
881
882 $type = isset( $schema['type'] ) ? (string) $schema['type'] : 'object';
883 if ( $type !== 'object' ) {
884 error_log( '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" has unsupported schema type: ' . $type );
885 return null;
886 }
887
888 $properties = [];
889 if ( isset( $schema['properties'] ) && ( is_array( $schema['properties'] ) || is_object( $schema['properties'] ) ) ) {
890 foreach ( (array) $schema['properties'] as $prop_name => $definition ) {
891 if ( !is_array( $definition ) ) {
892 $definition = [];
893 }
894
895 if ( isset( $definition['type'] ) ) {
896 // Validate type definition
897 if ( is_array( $definition['type'] ) ) {
898 // Array of types (union types) - validate they're compatible with MCP clients
899 $type_array = array_map( 'strval', $definition['type'] );
900
901 // Check for complex types that need additional schema details
902 $complex_types = array_intersect( $type_array, [ 'object', 'array' ] );
903 if ( !empty( $complex_types ) ) {
904 error_log(
905 '[AI Engine MCP] ⚠️ Tool "' . $tool_name . '" property "' . $prop_name .
906 '" has problematic union type with complex types: [' . implode( ', ', $type_array ) .
907 ']. This breaks ChatGPT. Auto-fixing by removing type constraint.'
908 );
909 // Auto-fix: Remove the type constraint to accept any value
910 unset( $definition['type'] );
911 // Keep description if present, or add one
912 if ( !isset( $definition['description'] ) ) {
913 $definition['description'] = 'Value can be of any type';
914 }
915 }
916 else {
917 $definition['type'] = $type_array;
918 }
919 }
920 else {
921 $definition['type'] = (string) $definition['type'];
922 }
923 }
924
925 $properties[ $prop_name ] = $definition;
926 }
927 }
928
929 $required = [];
930 if ( isset( $schema['required'] ) && is_array( $schema['required'] ) ) {
931 foreach ( $schema['required'] as $field ) {
932 $field_name = trim( (string) $field );
933 if ( $field_name !== '' ) {
934 $required[] = $field_name;
935 }
936 }
937 $required = array_values( array_unique( $required ) );
938 }
939
940 $normalized = [
941 'type' => 'object',
942 'properties' => empty( $properties ) ? new stdClass() : $properties,
943 ];
944
945 if ( !empty( $required ) ) {
946 $normalized['required'] = $required;
947 }
948
949 if ( array_key_exists( 'additionalProperties', $schema ) ) {
950 $normalized['additionalProperties'] = (bool) $schema['additionalProperties'];
951 }
952
953 return $normalized;
954 }
955
956 private function normalize_annotations( array $annotations, string $tool_name ): array {
957 $allowed_keys = [ 'title', 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ];
958 $normalized = [];
959
960 foreach ( $annotations as $key => $value ) {
961 if ( !in_array( $key, $allowed_keys, true ) ) {
962 continue;
963 }
964
965 if ( in_array( $key, [ 'readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint' ], true ) ) {
966 $normalized[ $key ] = (bool) $value;
967 }
968 elseif ( $key === 'title' ) {
969 $normalized['title'] = wp_strip_all_tags( (string) $value );
970 }
971 }
972
973 if ( empty( $normalized ) && $this->logging && !empty( $annotations ) ) {
974 error_log( '[AI Engine MCP] 🔎 Tool "' . $tool_name . '" included unsupported annotation keys.' );
975 }
976
977 return $normalized;
978 }
979 #endregion
980
981 #region Tools Call (execute_tool)
982 private function execute_tool( $tool, $args, $id ) {
983 $start = microtime( true );
984 $response = null;
985 $status = 'error';
986 $error_msg = null;
987 try {
988 // Ensure tool access levels are populated (each HTTP request starts fresh)
989 if ( empty( $this->tool_access_levels ) ) {
990 $this->get_tools_list();
991 }
992
993 // Defense in depth: verify tool access even if it wasn't filtered from the listing
994 $tool_level = $this->tool_access_levels[ $tool ] ?? 'admin';
995 if ( !$this->role_has_access( $tool_level ) ) {
996 $error_msg = "Access denied: tool '{$tool}' requires '{$tool_level}' access.";
997 $response = $this->rpc_error( $id, -32600, $error_msg );
998 return $response;
999 }
1000
1001 // Handle built-in tools first
1002 if ( $tool === 'mcp_ping' ) {
1003 if ( $this->logging ) {
1004 $this->log( '🛠️ Tool: mcp_ping' );
1005 }
1006 $ping_data = [
1007 'time' => gmdate( 'Y-m-d H:i:s' ),
1008 'name' => get_bloginfo( 'name' ),
1009 ];
1010 $response = [
1011 'jsonrpc' => '2.0',
1012 'id' => $id,
1013 'result' => [
1014 'content' => [
1015 [
1016 'type' => 'text',
1017 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ),
1018 ],
1019 ],
1020 'data' => $ping_data,
1021 ],
1022 ];
1023 $status = 'success';
1024 return $response;
1025 }
1026
1027 // Let other modules handle their tools
1028 if ( $this->logging ) {
1029 // Log tool calls with more context
1030 $args_preview = '';
1031 if ( !empty( $args ) ) {
1032 // Show key args for common tools
1033 if ( isset( $args['ID'] ) ) {
1034 $args_preview = ' (ID: ' . $args['ID'] . ')';
1035 }
1036 elseif ( isset( $args['query'] ) ) {
1037 $args_preview = ' (query: "' . substr( $args['query'], 0, 30 ) . '...")';
1038 }
1039 elseif ( isset( $args['message'] ) ) {
1040 $args_preview = ' (message: "' . substr( $args['message'], 0, 30 ) . '...")';
1041 }
1042 }
1043 // Log to both error log and UI
1044 error_log( '[AI Engine MCP] 🛠️ ' . $tool . $args_preview );
1045 $this->log( '🛠️ Tool: ' . $tool . $args_preview );
1046 }
1047 $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this );
1048
1049 if ( $filtered !== null ) {
1050 // Check if it's already a full JSON-RPC response (backward compatibility)
1051 if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) {
1052 $response = $filtered;
1053 $status = isset( $filtered['error'] ) ? 'error' : 'success';
1054 if ( $status === 'error' ) {
1055 $error_msg = $filtered['error']['message'] ?? null;
1056 }
1057 return $response;
1058 }
1059
1060 // Otherwise, wrap the result in proper JSON-RPC format
1061 $response = [
1062 'jsonrpc' => '2.0',
1063 'id' => $id,
1064 'result' => $this->format_tool_result( $filtered ),
1065 ];
1066 $status = 'success';
1067 return $response;
1068 }
1069
1070 throw new Exception( "Unknown tool: {$tool}" );
1071 }
1072 catch ( Exception $e ) {
1073 $error_msg = $e->getMessage();
1074 $response = $this->rpc_error( $id, -32603, $error_msg );
1075 return $response;
1076 }
1077 finally {
1078 $duration_ms = (int) round( ( microtime( true ) - $start ) * 1000 );
1079 // Fire the action even on access denials and errors so admins can see
1080 // attempted-but-blocked tool calls in MCP Logs.
1081 do_action( 'mwai_mcp_tool_called', [
1082 'tool' => $tool,
1083 'args' => $args,
1084 'result' => $response,
1085 'status' => $status,
1086 'error_msg' => $error_msg,
1087 'duration_ms' => $duration_ms,
1088 'client_id' => $this->auth_client_id,
1089 'client_name' => $this->auth_client_name,
1090 'auth_method' => $this->auth_method,
1091 'request_id' => $id,
1092 'user_id' => get_current_user_id(),
1093 ] );
1094 }
1095 }
1096 #endregion
1097
1098 #region Handle /upload (one-time file upload via token)
1099 public function handle_upload( WP_REST_Request $request ) {
1100 $token = $request->get_param( 'token' );
1101 if ( empty( $token ) ) {
1102 return new WP_REST_Response( [ 'success' => false, 'message' => 'Missing token.' ], 400 );
1103 }
1104
1105 $transient_key = 'mwai_mcp_upload_' . $token;
1106 $data = get_transient( $transient_key );
1107 if ( empty( $data ) ) {
1108 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid or expired upload token.' ], 403 );
1109 }
1110
1111 // Immediately delete the transient so the token can only be used once
1112 delete_transient( $transient_key );
1113
1114 $files = $request->get_file_params();
1115 if ( empty( $files['file'] ) ) {
1116 return new WP_REST_Response( [ 'success' => false, 'message' => 'No file provided. Use: curl -X POST -F "file=@/path/to/file" "<url>"' ], 400 );
1117 }
1118
1119 $uploaded = $files['file'];
1120 if ( $uploaded['error'] !== UPLOAD_ERR_OK ) {
1121 return new WP_REST_Response( [ 'success' => false, 'message' => 'Upload error code: ' . $uploaded['error'] ], 400 );
1122 }
1123
1124 // Set admin context for media handling
1125 if ( !current_user_can( 'administrator' ) ) {
1126 wp_set_current_user( 1 );
1127 }
1128
1129 require_once ABSPATH . 'wp-admin/includes/file.php';
1130 require_once ABSPATH . 'wp-admin/includes/media.php';
1131 require_once ABSPATH . 'wp-admin/includes/image.php';
1132
1133 // Use the filename from the transient (sanitized at creation time)
1134 $file = [
1135 'name' => $data['filename'],
1136 'tmp_name' => $uploaded['tmp_name'],
1137 ];
1138
1139 $attachment_id = media_handle_sideload( $file, 0, $data['description'] );
1140 if ( is_wp_error( $attachment_id ) ) {
1141 return new WP_REST_Response( [ 'success' => false, 'message' => $attachment_id->get_error_message() ], 500 );
1142 }
1143
1144 if ( !empty( $data['title'] ) ) {
1145 wp_update_post( [ 'ID' => $attachment_id, 'post_title' => sanitize_text_field( $data['title'] ) ] );
1146 }
1147 if ( !empty( $data['alt'] ) ) {
1148 update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $data['alt'] ) );
1149 }
1150
1151 return new WP_REST_Response( [
1152 'success' => true,
1153 'attachment_id' => $attachment_id,
1154 'url' => wp_get_attachment_url( $attachment_id ),
1155 ], 200 );
1156 }
1157 #endregion
1158
1159 #region Message Queue (per-message transient)
1160 private function transient_key( $sess, $id ) {
1161 return "{$this->queue_key}_{$sess}_{$id}";
1162 }
1163
1164 private function store_message( $sess, $payload ) {
1165 if ( !$sess ) {
1166 return;
1167 }
1168 $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A';
1169 set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 );
1170 $this->log( "queued #{$idKey}" );
1171 }
1172
1173 private function fetch_messages( $sess ) {
1174 global $wpdb;
1175 $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%';
1176
1177 $rows = $wpdb->get_results(
1178 $wpdb->prepare(
1179 "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s",
1180 $like
1181 ),
1182 ARRAY_A
1183 );
1184
1185 $msgs = [];
1186 foreach ( $rows as $r ) {
1187 $msgs[] = maybe_unserialize( $r['option_value'] );
1188 delete_option( $r['option_name'] );
1189 }
1190 usort( $msgs, fn ( $a, $b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) );
1191 if ( $msgs ) {
1192 $this->log( 'flush ' . count( $msgs ) . ' msg(s)' );
1193 }
1194 return $msgs;
1195 }
1196 #endregion
1197
1198 #region Resources (note)
1199 /*--------------------------------------------------*/
1200 /**
1201 * MCP also supports “resources” – static or dynamic data a client can
1202 * retrieve by URL (e.g. `mcp://resource/posts/123`).
1203 */
1204 #endregion
1205 }
1206