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