mcp.conf
1 year ago
mcp.js
1 year ago
mcp.md
1 year ago
mcp.php
1 year ago
mcp_core.php
1 year ago
mcp_rest.php
1 year ago
realtime.php
1 year ago
mcp.php
556 lines
| 1 | <?php |
| 2 | |
| 3 | class Meow_MWAI_Labs_MCP { |
| 4 | private $core = null; |
| 5 | private $namespace = 'mcp/v1'; |
| 6 | private $server_version = '0.0.1'; |
| 7 | private $protocol_version = '2025-03-26'; |
| 8 | private $queue_key = 'mwai_mcp_msg'; |
| 9 | private $session_id = null; |
| 10 | private $logging = false; |
| 11 | private $last_action_time = 0; |
| 12 | private $bearer_token = null; |
| 13 | |
| 14 | #region Initialize |
| 15 | public function __construct( $core ) { |
| 16 | $this->core = $core; |
| 17 | add_action( 'rest_api_init', [ $this, 'rest_api_init' ] ); |
| 18 | } |
| 19 | |
| 20 | public function rest_api_init() { |
| 21 | $this->bearer_token = $this->core->get_option( 'mcp_bearer_token' ); |
| 22 | if ( !empty( $this->bearer_token ) ) { |
| 23 | add_filter( 'mwai_allow_mcp', [ $this, 'auth_via_bearer_token' ], 10, 2 ); |
| 24 | } |
| 25 | register_rest_route( $this->namespace, '/sse', [ |
| 26 | 'methods' => 'GET', |
| 27 | 'callback' => [ $this, 'handle_sse' ], |
| 28 | 'permission_callback' => function( $request ) { |
| 29 | return $this->can_access_mcp( $request ); |
| 30 | }, |
| 31 | ] ); |
| 32 | |
| 33 | register_rest_route( $this->namespace, '/sse', [ |
| 34 | 'methods' => 'POST', |
| 35 | 'callback' => [ $this, 'handle_sse' ], |
| 36 | 'permission_callback' => function( $request ) { |
| 37 | return $this->can_access_mcp( $request ); |
| 38 | }, |
| 39 | ] ); |
| 40 | |
| 41 | register_rest_route( $this->namespace, '/messages', [ |
| 42 | 'methods' => 'POST', |
| 43 | 'callback' => [ $this, 'handle_message' ], |
| 44 | 'permission_callback' => function( $request ) { |
| 45 | return $this->can_access_mcp( $request ); |
| 46 | }, |
| 47 | ] ); |
| 48 | } |
| 49 | #endregion |
| 50 | |
| 51 | #region Auth (Bearer token) |
| 52 | function can_access_mcp( $request ) { |
| 53 | $logged_in = is_user_logged_in(); |
| 54 | return apply_filters( 'mwai_allow_mcp', $logged_in, $request ); |
| 55 | } |
| 56 | |
| 57 | public function auth_via_bearer_token( $allow, $request ) { |
| 58 | if ( empty( $this->bearer_token ) ) { |
| 59 | return false; |
| 60 | } |
| 61 | $hdr = $request->get_header( 'authorization' ); |
| 62 | if ( $hdr && preg_match( '/Bearer\s+(.+)/i', $hdr, $m ) && |
| 63 | hash_equals( $this->bearer_token, trim( $m[1] ) ) ) { |
| 64 | if ( $admin = $this->core->get_admin_user() ) { |
| 65 | wp_set_current_user( $admin->ID, $admin->user_login ); |
| 66 | } |
| 67 | return true; |
| 68 | } |
| 69 | // ?token=xyz fallback (optional) |
| 70 | $q = sanitize_text_field( $request->get_param( 'token' ) ); |
| 71 | if ( $q && hash_equals( $this->bearer_token, $q ) ) return true; |
| 72 | return $allow; |
| 73 | } |
| 74 | #endregion |
| 75 | |
| 76 | #region Helpers (log / JSON-RPC utils) |
| 77 | private function log( $msg ) { |
| 78 | if ( $this->logging ) { |
| 79 | Meow_MWAI_Logging::log( "[MCP] ({$this->session_id}): {$msg}" ); |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | /** Wrap a JSON-RPC error object */ |
| 84 | private function rpc_error( $id, int $code, string $msg, $extra = null ): array { |
| 85 | $err = [ 'code' => $code, 'message' => $msg ]; |
| 86 | if ( $extra !== null ) $err['data'] = $extra; |
| 87 | return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ]; |
| 88 | } |
| 89 | |
| 90 | /** Queue an error for SSE delivery */ |
| 91 | private function queue_error( $sess, $id, int $code, string $msg, $extra = null ): void { |
| 92 | $this->store_message( $sess, $this->rpc_error( $id, $code, $msg, $extra ) ); |
| 93 | } |
| 94 | |
| 95 | /** Format tool result for MCP protocol */ |
| 96 | private function format_tool_result( $result ) : array { |
| 97 | // If result is a string, wrap it in the MCP content format |
| 98 | if ( is_string( $result ) ) { |
| 99 | return [ |
| 100 | 'content' => [ |
| 101 | [ |
| 102 | 'type' => 'text', |
| 103 | 'text' => $result, |
| 104 | ], |
| 105 | ], |
| 106 | ]; |
| 107 | } |
| 108 | |
| 109 | // If result has 'content' key, assume it's already properly formatted |
| 110 | if ( is_array( $result ) && isset( $result['content'] ) ) { |
| 111 | return $result; |
| 112 | } |
| 113 | |
| 114 | // If result is an array without 'content' key, wrap it as JSON |
| 115 | if ( is_array( $result ) ) { |
| 116 | return [ |
| 117 | 'content' => [ |
| 118 | [ |
| 119 | 'type' => 'text', |
| 120 | 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT ), |
| 121 | ], |
| 122 | ], |
| 123 | 'data' => $result, |
| 124 | ]; |
| 125 | } |
| 126 | |
| 127 | // For any other type, convert to string and wrap |
| 128 | return [ |
| 129 | 'content' => [ |
| 130 | [ |
| 131 | 'type' => 'text', |
| 132 | 'text' => (string) $result, |
| 133 | ], |
| 134 | ], |
| 135 | ]; |
| 136 | } |
| 137 | #endregion |
| 138 | |
| 139 | #region Handle direct JSON-RPC (for Claude's MCP client) |
| 140 | /** |
| 141 | * Claude's MCP client (via Anthropic API) sends JSON-RPC requests directly to the SSE endpoint |
| 142 | * as POST requests, rather than following the typical SSE flow: |
| 143 | * - Normal flow: GET /sse → establish SSE stream → POST /messages for JSON-RPC |
| 144 | * - Claude's flow: POST /sse with JSON-RPC body → expect immediate JSON response |
| 145 | * |
| 146 | * This method handles the direct JSON-RPC requests to maintain compatibility with Claude. |
| 147 | */ |
| 148 | private function handle_direct_jsonrpc( WP_REST_Request $request, $data ) { |
| 149 | $id = $data['id'] ?? null; |
| 150 | $method = $data['method'] ?? null; |
| 151 | |
| 152 | if ( json_last_error() !== JSON_ERROR_NONE ) { |
| 153 | return new WP_REST_Response( [ |
| 154 | 'jsonrpc' => '2.0', |
| 155 | 'id' => null, |
| 156 | 'error' => [ 'code' => -32700, 'message' => 'Parse error: invalid JSON' ] |
| 157 | ], 200 ); |
| 158 | } |
| 159 | |
| 160 | if ( ! is_array( $data ) || !$method ) { |
| 161 | return new WP_REST_Response( [ |
| 162 | 'jsonrpc' => '2.0', |
| 163 | 'id' => $id, |
| 164 | 'error' => [ 'code' => -32600, 'message' => 'Invalid Request' ] |
| 165 | ], 200 ); |
| 166 | } |
| 167 | |
| 168 | try { |
| 169 | $reply = null; |
| 170 | |
| 171 | switch ( $method ) { |
| 172 | case 'initialize': |
| 173 | // Check if client requests a specific protocol version |
| 174 | $params = $data['params'] ?? []; |
| 175 | $requested_version = $params['protocolVersion'] ?? null; |
| 176 | |
| 177 | if ( $requested_version && $requested_version !== $this->protocol_version ) { |
| 178 | if ( $this->logging ) { |
| 179 | Meow_MWAI_Logging::warn( "[MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" ); |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | $reply = [ |
| 184 | 'jsonrpc' => '2.0', |
| 185 | 'id' => $id, |
| 186 | 'result' => [ |
| 187 | 'protocolVersion' => $this->protocol_version, |
| 188 | 'serverInfo' => (object)[ |
| 189 | 'name' => get_bloginfo( 'name' ) . ' MCP', |
| 190 | 'version' => $this->server_version, |
| 191 | ], |
| 192 | 'capabilities' => [ |
| 193 | 'tools' => [ 'listChanged' => true ], |
| 194 | 'prompts' => [ 'subscribe' => false, 'listChanged' => false ], |
| 195 | 'resources' => [ 'listChanged' => false ], |
| 196 | ], |
| 197 | ], |
| 198 | ]; |
| 199 | break; |
| 200 | |
| 201 | case 'tools/list': |
| 202 | $reply = [ |
| 203 | 'jsonrpc' => '2.0', |
| 204 | 'id' => $id, |
| 205 | 'result' => [ 'tools' => $this->get_tools_list() ], |
| 206 | ]; |
| 207 | break; |
| 208 | |
| 209 | case 'tools/call': |
| 210 | $params = $data['params'] ?? []; |
| 211 | $tool = $params['name'] ?? ''; |
| 212 | $arguments = $params['arguments']?? []; |
| 213 | $reply = $this->execute_tool( $tool, $arguments, $id ); |
| 214 | break; |
| 215 | |
| 216 | default: |
| 217 | $reply = [ |
| 218 | 'jsonrpc' => '2.0', |
| 219 | 'id' => $id, |
| 220 | 'error' => [ 'code' => -32601, 'message' => "Method not found: {$method}" ] |
| 221 | ]; |
| 222 | } |
| 223 | |
| 224 | return new WP_REST_Response( $reply, 200 ); |
| 225 | |
| 226 | } catch ( Exception $e ) { |
| 227 | return new WP_REST_Response( [ |
| 228 | 'jsonrpc' => '2.0', |
| 229 | 'id' => $id, |
| 230 | 'error' => [ 'code' => -32603, 'message' => 'Internal error', 'data' => $e->getMessage() ] |
| 231 | ], 200 ); |
| 232 | } |
| 233 | } |
| 234 | #endregion |
| 235 | |
| 236 | #region Handle SSE (stream loop) |
| 237 | private function reply( string $event, $data = null, string $enc = 'json' ) { |
| 238 | if ( $enc === 'json' && $data === null ) { |
| 239 | $this->log( "no data for {$event}"); |
| 240 | return; |
| 241 | } |
| 242 | echo "event: {$event}\n"; |
| 243 | if ( $enc === 'json' ) |
| 244 | $data = $data === null ? '{}' : wp_json_encode( $data, JSON_UNESCAPED_UNICODE ); |
| 245 | echo 'data: ' . $data . "\n\n"; |
| 246 | |
| 247 | if ( ob_get_level() ) ob_end_flush(); |
| 248 | flush(); |
| 249 | |
| 250 | $this->last_action_time = time(); |
| 251 | $this->log( "→ {$event}" ); |
| 252 | } |
| 253 | |
| 254 | private function generate_sse_id( $req ) { |
| 255 | $last = $req ? $req->get_header( 'last-event-id' ) : ''; |
| 256 | return $last ?: str_replace( '-', '', wp_generate_uuid4() ); |
| 257 | } |
| 258 | |
| 259 | public function handle_sse( WP_REST_Request $request ) { |
| 260 | |
| 261 | $raw_body = $request->get_body(); |
| 262 | if ( $this->logging ) { |
| 263 | Meow_MWAI_Logging::log( '[MCP] Raw Body: ' . $raw_body ); |
| 264 | } |
| 265 | |
| 266 | // Handle POST request with JSON-RPC body (Claude's MCP client behavior) |
| 267 | // Claude's API sends JSON-RPC requests directly to the SSE endpoint instead of |
| 268 | // establishing an SSE connection first. This is non-standard but we need to support it. |
| 269 | // Expected: GET /sse (establish stream) → POST /messages (send JSON-RPC) |
| 270 | // Claude: POST /sse with JSON-RPC body → expects immediate JSON response |
| 271 | if ( $request->get_method() === 'POST' && !empty( $raw_body ) ) { |
| 272 | $data = json_decode( $raw_body, true ); |
| 273 | if ( $data && isset( $data['method'] ) ) { |
| 274 | if ( $this->logging ) { |
| 275 | Meow_MWAI_Logging::log( '[MCP] Handling direct JSON-RPC for method: ' . $data['method'] ); |
| 276 | } |
| 277 | // Process as a direct JSON-RPC request instead of starting SSE stream |
| 278 | return $this->handle_direct_jsonrpc( $request, $data ); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | @ini_set( 'zlib.output_compression', '0' ); |
| 283 | @ini_set( 'output_buffering', '0' ); |
| 284 | @ini_set( 'implicit_flush', '1' ); |
| 285 | if ( function_exists( 'ob_implicit_flush' ) ) ob_implicit_flush( true ); |
| 286 | |
| 287 | header( 'Content-Type: text/event-stream' ); |
| 288 | header( 'Cache-Control: no-cache' ); |
| 289 | header( 'X-Accel-Buffering: no' ); |
| 290 | header( 'Connection: keep-alive' ); |
| 291 | while ( ob_get_level() ) ob_end_flush(); |
| 292 | |
| 293 | /* — greet client —*/ |
| 294 | $this->session_id = $this->generate_sse_id( $request ); |
| 295 | $this->last_action_time = time(); |
| 296 | echo "id: {$this->session_id}\n\n"; |
| 297 | flush(); |
| 298 | |
| 299 | $msg_uri = sprintf( |
| 300 | '%s/messages?session_id=%s', |
| 301 | rest_url( $this->namespace ), |
| 302 | $this->session_id |
| 303 | ); |
| 304 | $this->reply( 'endpoint', $msg_uri, 'text' ); |
| 305 | $this->log( 'SSE started' ); |
| 306 | |
| 307 | /* — main loop —*/ |
| 308 | while ( true ) { |
| 309 | $max_time = 60 * 5; // 5 minutes |
| 310 | $idle = ( time() - $this->last_action_time ) >= $max_time; |
| 311 | if ( connection_aborted() || $idle ) { |
| 312 | $this->reply( 'bye' ); |
| 313 | $this->log( $idle ? 'idle-timeout' : 'client abort' ); |
| 314 | break; |
| 315 | } |
| 316 | |
| 317 | foreach ( $this->fetch_messages( $this->session_id ) as $p ) |
| 318 | $this->reply( 'message', $p ); |
| 319 | |
| 320 | usleep( 200000 ); // 200 ms |
| 321 | } |
| 322 | exit; |
| 323 | } |
| 324 | #endregion |
| 325 | |
| 326 | #region Handle /messages (JSON-RPC ingress) |
| 327 | public function handle_message( WP_REST_Request $request ) { |
| 328 | $sess = sanitize_text_field( $request->get_param( 'session_id' ) ); |
| 329 | $raw = $request->get_body(); |
| 330 | $dat = json_decode( $raw, true ); |
| 331 | |
| 332 | if ( json_last_error() !== JSON_ERROR_NONE ) { |
| 333 | $this->queue_error( $sess, null, -32700, 'Parse error: invalid JSON' ); |
| 334 | return new WP_REST_Response( null, 204 ); |
| 335 | } |
| 336 | if ( ! is_array( $dat ) ) { |
| 337 | $this->queue_error( $sess, null, -32600, 'Invalid Request' ); |
| 338 | return new WP_REST_Response( null, 204 ); |
| 339 | } |
| 340 | |
| 341 | $id = $dat['id'] ?? null; |
| 342 | $method = $dat['method'] ?? null; |
| 343 | |
| 344 | /* — notifications —*/ |
| 345 | if ( $method === 'initialized' ) return new WP_REST_Response( null, 204 ); |
| 346 | if ( $method === 'mwai/kill' ) exit(0); |
| 347 | |
| 348 | // It's a notification, no ID = no reply |
| 349 | if ( $id === null && $method !== null ) { |
| 350 | return new WP_REST_Response( null, 204 ); |
| 351 | } |
| 352 | |
| 353 | if ( !$method ) { |
| 354 | $this->queue_error( $sess, $id, -32600, 'Invalid Request: method missing' ); |
| 355 | return new WP_REST_Response( null, 204 ); |
| 356 | } |
| 357 | |
| 358 | try { |
| 359 | |
| 360 | $reply = null; |
| 361 | |
| 362 | #region Methods switch |
| 363 | switch ( $method ) { |
| 364 | |
| 365 | case 'initialize': |
| 366 | // Check if client requests a specific protocol version |
| 367 | $params = $data['params'] ?? []; |
| 368 | $requested_version = $params['protocolVersion'] ?? null; |
| 369 | |
| 370 | if ( $requested_version && $requested_version !== $this->protocol_version ) { |
| 371 | if ( $this->logging ) { |
| 372 | Meow_MWAI_Logging::warn( "[MCP] Client requested protocol version {$requested_version}, but we only support {$this->protocol_version}" ); |
| 373 | } |
| 374 | } |
| 375 | |
| 376 | $reply = [ |
| 377 | 'jsonrpc' => '2.0', |
| 378 | 'id' => $id, |
| 379 | 'result' => [ |
| 380 | 'protocolVersion' => $this->protocol_version, |
| 381 | 'serverInfo' => (object)[ |
| 382 | 'name' => get_bloginfo( 'name' ) . ' MCP', |
| 383 | 'version' => $this->server_version, |
| 384 | ], |
| 385 | 'capabilities' => [ |
| 386 | 'tools' => [ 'listChanged' => true ], |
| 387 | 'prompts' => [ 'subscribe' => false, 'listChanged' => false ], |
| 388 | 'resources' => [ 'listChanged' => false ], |
| 389 | ], |
| 390 | ], |
| 391 | ]; |
| 392 | break; |
| 393 | |
| 394 | case 'tools/list': |
| 395 | $reply = [ |
| 396 | 'jsonrpc' => '2.0', |
| 397 | 'id' => $id, |
| 398 | 'result' => [ 'tools' => $this->get_tools_list() ], |
| 399 | ]; |
| 400 | break; |
| 401 | |
| 402 | case 'resources/list': |
| 403 | $reply = [ |
| 404 | 'jsonrpc' => '2.0', |
| 405 | 'id' => $id, |
| 406 | 'result' => [ 'resources' => $this->get_resources_list() ], |
| 407 | ]; |
| 408 | break; |
| 409 | |
| 410 | case 'prompts/list': |
| 411 | $reply = [ |
| 412 | 'jsonrpc' => '2.0', |
| 413 | 'id' => $id, |
| 414 | 'result' => [ 'prompts' => $this->get_prompts_list() ], |
| 415 | ]; |
| 416 | break; |
| 417 | |
| 418 | case 'tools/call': |
| 419 | $params = $dat['params'] ?? []; |
| 420 | $tool = $params['name'] ?? ''; |
| 421 | $arguments = $params['arguments']?? []; |
| 422 | $reply = $this->execute_tool( $tool, $arguments, $id ); |
| 423 | break; |
| 424 | |
| 425 | default: |
| 426 | $reply = $this->rpc_error( $id, -32601, "Method not found: {$method}" ); |
| 427 | } |
| 428 | #endregion |
| 429 | |
| 430 | if ( $reply ) $this->store_message( $sess, $reply ); |
| 431 | |
| 432 | } catch ( Exception $e ) { |
| 433 | $this->queue_error( $sess, $id, -32603, 'Internal error', $e->getMessage() ); |
| 434 | } |
| 435 | |
| 436 | return new WP_REST_Response( null, 204 ); |
| 437 | } |
| 438 | #endregion |
| 439 | |
| 440 | #region Tools Definitions |
| 441 | private function get_tools_list() { |
| 442 | $base_tools = [ |
| 443 | [ |
| 444 | 'name' => 'mcp_ping', |
| 445 | '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.', |
| 446 | 'inputSchema' => [ 'type' => 'object', 'properties' => (object) [] ], |
| 447 | ], |
| 448 | ]; |
| 449 | return apply_filters( 'mwai_mcp_tools', $base_tools ); |
| 450 | } |
| 451 | #endregion |
| 452 | |
| 453 | #region Resources Definitions |
| 454 | private function get_resources_list() { |
| 455 | return []; |
| 456 | } |
| 457 | #endregion |
| 458 | |
| 459 | #region Prompts Definitions |
| 460 | private function get_prompts_list() { |
| 461 | return []; |
| 462 | } |
| 463 | #endregion |
| 464 | |
| 465 | #region Tools Call (execute_tool) |
| 466 | private function execute_tool( $tool, $args, $id ) { |
| 467 | try { |
| 468 | // Handle built-in tools first |
| 469 | if ( $tool === 'mcp_ping' ) { |
| 470 | $ping_data = [ |
| 471 | 'time' => gmdate( 'Y-m-d H:i:s' ), |
| 472 | 'name' => get_bloginfo( 'name' ), |
| 473 | ]; |
| 474 | return [ |
| 475 | 'jsonrpc' => '2.0', |
| 476 | 'id' => $id, |
| 477 | 'result' => [ |
| 478 | 'content' => [ |
| 479 | [ |
| 480 | 'type' => 'text', |
| 481 | 'text' => 'Ping successful: ' . wp_json_encode( $ping_data, JSON_PRETTY_PRINT ), |
| 482 | ], |
| 483 | ], |
| 484 | 'data' => $ping_data, |
| 485 | ], |
| 486 | ]; |
| 487 | } |
| 488 | |
| 489 | // Let other modules handle their tools |
| 490 | $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this ); |
| 491 | |
| 492 | if ( $filtered !== null ) { |
| 493 | // Check if it's already a full JSON-RPC response (backward compatibility) |
| 494 | if ( is_array( $filtered ) && isset( $filtered['jsonrpc'] ) && isset( $filtered['id'] ) ) { |
| 495 | return $filtered; |
| 496 | } |
| 497 | |
| 498 | // Otherwise, wrap the result in proper JSON-RPC format |
| 499 | return [ |
| 500 | 'jsonrpc' => '2.0', |
| 501 | 'id' => $id, |
| 502 | 'result' => $this->format_tool_result( $filtered ), |
| 503 | ]; |
| 504 | } |
| 505 | |
| 506 | throw new Exception( "Unknown tool: {$tool}" ); |
| 507 | } catch ( Exception $e ) { |
| 508 | return $this->rpc_error( $id, -32603, $e->getMessage() ); |
| 509 | } |
| 510 | } |
| 511 | #endregion |
| 512 | |
| 513 | #region Message Queue (per-message transient) |
| 514 | private function transient_key( $sess, $id ) { |
| 515 | return "{$this->queue_key}_{$sess}_{$id}"; |
| 516 | } |
| 517 | |
| 518 | private function store_message( $sess, $payload ) { |
| 519 | if ( !$sess ) return; |
| 520 | $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A'; |
| 521 | set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 ); |
| 522 | $this->log( "queued #{$idKey}" ); |
| 523 | } |
| 524 | |
| 525 | private function fetch_messages( $sess ) { |
| 526 | global $wpdb; |
| 527 | $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%'; |
| 528 | |
| 529 | $rows = $wpdb->get_results( |
| 530 | $wpdb->prepare( |
| 531 | "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", |
| 532 | $like |
| 533 | ), |
| 534 | ARRAY_A |
| 535 | ); |
| 536 | |
| 537 | $msgs = []; |
| 538 | foreach ( $rows as $r ) { |
| 539 | $msgs[] = maybe_unserialize( $r['option_value'] ); |
| 540 | delete_option( $r['option_name'] ); |
| 541 | } |
| 542 | usort( $msgs, fn( $a,$b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) ); |
| 543 | if ( $msgs ) $this->log( 'flush '.count($msgs).' msg(s)' ); |
| 544 | return $msgs; |
| 545 | } |
| 546 | #endregion |
| 547 | |
| 548 | #region Resources (note) |
| 549 | /*--------------------------------------------------*/ |
| 550 | /** |
| 551 | * MCP also supports “resources” – static or dynamic data a client can |
| 552 | * retrieve by URL (e.g. `mcp://resource/posts/123`). |
| 553 | */ |
| 554 | #endregion |
| 555 | } |
| 556 |