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
328 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 $queue_key = 'mwai_mcp_msg'; |
| 8 | private $session_id = null; |
| 9 | private $logging = false; |
| 10 | private $last_action_time = 0; |
| 11 | private $bearer_token = null; |
| 12 | |
| 13 | #region Initialize |
| 14 | public function __construct( $core ) { |
| 15 | $this->core = $core; |
| 16 | add_action( 'rest_api_init', [ $this, 'rest_api_init' ] ); |
| 17 | } |
| 18 | |
| 19 | public function rest_api_init() { |
| 20 | $this->bearer_token = $this->core->get_option( 'mcp_bearer_token' ); |
| 21 | if ( !empty( $this->bearer_token ) ) { |
| 22 | add_filter( 'mwai_allow_mcp', [ $this, 'auth_via_bearer_token' ], 10, 2 ); |
| 23 | } |
| 24 | register_rest_route( $this->namespace, '/sse', [ |
| 25 | 'methods' => 'GET', |
| 26 | 'callback' => [ $this, 'handle_sse' ], |
| 27 | 'permission_callback' => function( $request ) { |
| 28 | return $this->can_access_mcp( $request ); |
| 29 | }, |
| 30 | ] ); |
| 31 | |
| 32 | register_rest_route( $this->namespace, '/messages', [ |
| 33 | 'methods' => 'POST', |
| 34 | 'callback' => [ $this, 'handle_message' ], |
| 35 | 'permission_callback' => function( $request ) { |
| 36 | return $this->can_access_mcp( $request ); |
| 37 | }, |
| 38 | ] ); |
| 39 | } |
| 40 | #endregion |
| 41 | |
| 42 | #region Auth (Bearer token) |
| 43 | function can_access_mcp( $request ) { |
| 44 | $logged_in = is_user_logged_in(); |
| 45 | return apply_filters( 'mwai_allow_mcp', $logged_in, $request ); |
| 46 | } |
| 47 | |
| 48 | public function auth_via_bearer_token( $allow, $request ) { |
| 49 | $hdr = $request->get_header( 'authorization' ); |
| 50 | if ( $hdr && preg_match( '/Bearer\s+(.+)/i', $hdr, $m ) && |
| 51 | hash_equals( $this->bearer_token, trim( $m[1] ) ) ) { |
| 52 | if ( $admin = $this->core->get_admin_user() ) { |
| 53 | wp_set_current_user( $admin->ID, $admin->user_login ); |
| 54 | } |
| 55 | return true; |
| 56 | } |
| 57 | // ?token=xyz fallback (optional) |
| 58 | $q = sanitize_text_field( $request->get_param( 'token' ) ); |
| 59 | if ( $q && hash_equals( $this->bearer_token, $q ) ) return true; |
| 60 | return $allow; |
| 61 | } |
| 62 | #endregion |
| 63 | |
| 64 | #region Helpers (log / JSON-RPC utils) |
| 65 | private function log( $msg ) { |
| 66 | if ( $this->logging ) error_log( "MCP ({$this->session_id}): {$msg}" ); |
| 67 | } |
| 68 | |
| 69 | /** Wrap a JSON-RPC error object */ |
| 70 | private function rpc_error( $id, int $code, string $msg, $extra = null ): array { |
| 71 | $err = [ 'code' => $code, 'message' => $msg ]; |
| 72 | if ( $extra !== null ) $err['data'] = $extra; |
| 73 | return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => $err ]; |
| 74 | } |
| 75 | |
| 76 | /** Queue an error for SSE delivery */ |
| 77 | private function queue_error( $sess, $id, int $code, string $msg, $extra = null ): void { |
| 78 | $this->store_message( $sess, $this->rpc_error( $id, $code, $msg, $extra ) ); |
| 79 | } |
| 80 | #endregion |
| 81 | |
| 82 | #region Handle SSE (stream loop) |
| 83 | private function reply( string $event, $data = null, string $enc = 'json' ) { |
| 84 | if ( $enc === 'json' && $data === null ) { |
| 85 | $this->log( "no data for {$event}"); |
| 86 | return; |
| 87 | } |
| 88 | echo "event: {$event}\n"; |
| 89 | if ( $enc === 'json' ) |
| 90 | $data = $data === null ? '{}' : wp_json_encode( $data, JSON_UNESCAPED_UNICODE ); |
| 91 | echo 'data: ' . $data . "\n\n"; |
| 92 | |
| 93 | if ( ob_get_level() ) ob_end_flush(); |
| 94 | flush(); |
| 95 | |
| 96 | $this->last_action_time = time(); |
| 97 | $this->log( "→ {$event}" ); |
| 98 | } |
| 99 | |
| 100 | private function generate_sse_id( $req ) { |
| 101 | $last = $req ? $req->get_header( 'last-event-id' ) : ''; |
| 102 | return $last ?: str_replace( '-', '', wp_generate_uuid4() ); |
| 103 | } |
| 104 | |
| 105 | public function handle_sse( WP_REST_Request $request ) { |
| 106 | @ini_set( 'zlib.output_compression', '0' ); |
| 107 | @ini_set( 'output_buffering', '0' ); |
| 108 | @ini_set( 'implicit_flush', '1' ); |
| 109 | if ( function_exists( 'ob_implicit_flush' ) ) ob_implicit_flush( true ); |
| 110 | |
| 111 | header( 'Content-Type: text/event-stream' ); |
| 112 | header( 'Cache-Control: no-cache' ); |
| 113 | header( 'X-Accel-Buffering: no' ); |
| 114 | header( 'Connection: keep-alive' ); |
| 115 | while ( ob_get_level() ) ob_end_flush(); |
| 116 | |
| 117 | /* — greet client —*/ |
| 118 | $this->session_id = $this->generate_sse_id( $request ); |
| 119 | $this->last_action_time = time(); |
| 120 | echo "id: {$this->session_id}\n\n"; |
| 121 | flush(); |
| 122 | |
| 123 | $msg_uri = sprintf( |
| 124 | '%s/messages?session_id=%s', |
| 125 | rest_url( $this->namespace ), |
| 126 | $this->session_id |
| 127 | ); |
| 128 | $this->reply( 'endpoint', $msg_uri, 'text' ); |
| 129 | $this->log( 'SSE started' ); |
| 130 | |
| 131 | /* — main loop —*/ |
| 132 | while ( true ) { |
| 133 | $max_time = 60 * 5; // 5 minutes |
| 134 | $idle = ( time() - $this->last_action_time ) >= $max_time; |
| 135 | if ( connection_aborted() || $idle ) { |
| 136 | $this->reply( 'bye' ); |
| 137 | $this->log( $idle ? 'idle-timeout' : 'client abort' ); |
| 138 | break; |
| 139 | } |
| 140 | |
| 141 | foreach ( $this->fetch_messages( $this->session_id ) as $p ) |
| 142 | $this->reply( 'message', $p ); |
| 143 | |
| 144 | usleep( 200000 ); // 200 ms |
| 145 | } |
| 146 | exit; |
| 147 | } |
| 148 | #endregion |
| 149 | |
| 150 | #region Handle /messages (JSON-RPC ingress) |
| 151 | public function handle_message( WP_REST_Request $request ) { |
| 152 | |
| 153 | $sess = sanitize_text_field( $request->get_param( 'session_id' ) ); |
| 154 | $raw = $request->get_body(); |
| 155 | $dat = json_decode( $raw, true ); |
| 156 | |
| 157 | if ( json_last_error() !== JSON_ERROR_NONE ) { |
| 158 | $this->queue_error( $sess, null, -32700, 'Parse error: invalid JSON' ); |
| 159 | return new WP_REST_Response( null, 204 ); |
| 160 | } |
| 161 | if ( ! is_array( $dat ) ) { |
| 162 | $this->queue_error( $sess, null, -32600, 'Invalid Request' ); |
| 163 | return new WP_REST_Response( null, 204 ); |
| 164 | } |
| 165 | |
| 166 | $id = $dat['id'] ?? null; |
| 167 | $method = $dat['method'] ?? null; |
| 168 | |
| 169 | /* — notifications —*/ |
| 170 | if ( $method === 'initialized' ) return new WP_REST_Response( null, 204 ); |
| 171 | if ( $method === 'mwai/kill' ) exit(0); |
| 172 | |
| 173 | // It's a notification, no ID = no reply |
| 174 | if ( $id === null && $method !== null ) { |
| 175 | return new WP_REST_Response( null, 204 ); |
| 176 | } |
| 177 | |
| 178 | if ( !$method ) { |
| 179 | $this->queue_error( $sess, $id, -32600, 'Invalid Request: method missing' ); |
| 180 | return new WP_REST_Response( null, 204 ); |
| 181 | } |
| 182 | |
| 183 | try { |
| 184 | |
| 185 | $reply = null; |
| 186 | |
| 187 | #region Methods switch |
| 188 | switch ( $method ) { |
| 189 | |
| 190 | case 'initialize': |
| 191 | $reply = [ |
| 192 | 'jsonrpc' => '2.0', |
| 193 | 'id' => $id, |
| 194 | 'result' => [ |
| 195 | 'protocolVersion' => '2025-03-26', |
| 196 | 'serverInfo' => (object)[ |
| 197 | 'name' => get_bloginfo( 'name' ) . ' MCP', |
| 198 | 'version' => $this->server_version, |
| 199 | ], |
| 200 | 'capabilities' => [ |
| 201 | 'tools' => [ 'listChanged' => true ], |
| 202 | 'prompts' => [ 'subscribe' => false, 'listChanged' => false ], |
| 203 | 'resources' => [ 'listChanged' => false ], |
| 204 | ], |
| 205 | ], |
| 206 | ]; |
| 207 | break; |
| 208 | |
| 209 | case 'tools/list': |
| 210 | $reply = [ |
| 211 | 'jsonrpc' => '2.0', |
| 212 | 'id' => $id, |
| 213 | 'result' => [ 'tools' => $this->get_tools_list() ], |
| 214 | ]; |
| 215 | break; |
| 216 | |
| 217 | case 'resources/list': |
| 218 | $reply = [ |
| 219 | 'jsonrpc' => '2.0', |
| 220 | 'id' => $id, |
| 221 | 'result' => [ 'resources' => $this->get_resources_list() ], |
| 222 | ]; |
| 223 | break; |
| 224 | |
| 225 | case 'prompts/list': |
| 226 | $reply = [ |
| 227 | 'jsonrpc' => '2.0', |
| 228 | 'id' => $id, |
| 229 | 'result' => [ 'prompts' => $this->get_prompts_list() ], |
| 230 | ]; |
| 231 | break; |
| 232 | |
| 233 | case 'tools/call': |
| 234 | $params = $dat['params'] ?? []; |
| 235 | $tool = $params['name'] ?? ''; |
| 236 | $arguments = $params['arguments']?? []; |
| 237 | $reply = $this->execute_tool( $tool, $arguments, $id ); |
| 238 | break; |
| 239 | |
| 240 | default: |
| 241 | $reply = $this->rpc_error( $id, -32601, "Method not found: {$method}" ); |
| 242 | } |
| 243 | #endregion |
| 244 | |
| 245 | if ( $reply ) $this->store_message( $sess, $reply ); |
| 246 | |
| 247 | } catch ( Exception $e ) { |
| 248 | $this->queue_error( $sess, $id, -32603, 'Internal error', $e->getMessage() ); |
| 249 | } |
| 250 | |
| 251 | return new WP_REST_Response( null, 204 ); |
| 252 | } |
| 253 | #endregion |
| 254 | |
| 255 | #region Tools Definitions |
| 256 | private function get_tools_list() { |
| 257 | return apply_filters( 'mwai_mcp_tools', [] ); |
| 258 | } |
| 259 | #endregion |
| 260 | |
| 261 | #region Resources Definitions |
| 262 | private function get_resources_list() { |
| 263 | return []; |
| 264 | } |
| 265 | #endregion |
| 266 | |
| 267 | #region Prompts Definitions |
| 268 | private function get_prompts_list() { |
| 269 | return []; |
| 270 | } |
| 271 | #endregion |
| 272 | |
| 273 | #region Tools Call (execute_tool) |
| 274 | private function execute_tool( $tool, $args, $id ) { |
| 275 | try { |
| 276 | $filtered = apply_filters( 'mwai_mcp_callback', null, $tool, $args, $id, $this ); |
| 277 | if ( is_array( $filtered ) && isset( $filtered['id'] ) ) return $filtered; |
| 278 | throw new Exception( "Unknown tool: {$tool}" ); |
| 279 | } catch ( Exception $e ) { |
| 280 | return $this->rpc_error( $id, -32603, $e->getMessage() ); |
| 281 | } |
| 282 | } |
| 283 | #endregion |
| 284 | |
| 285 | #region Message Queue (per-message transient) |
| 286 | private function transient_key( $sess, $id ) { |
| 287 | return "{$this->queue_key}_{$sess}_{$id}"; |
| 288 | } |
| 289 | |
| 290 | private function store_message( $sess, $payload ) { |
| 291 | if ( !$sess ) return; |
| 292 | $idKey = array_key_exists( 'id', $payload ) ? ( $payload['id'] ?? 'NULL' ) : 'N/A'; |
| 293 | set_transient( $this->transient_key( $sess, $idKey ), $payload, 30 ); |
| 294 | $this->log( "queued #{$idKey}" ); |
| 295 | } |
| 296 | |
| 297 | private function fetch_messages( $sess ) { |
| 298 | global $wpdb; |
| 299 | $like = $wpdb->esc_like( '_transient_' . "{$this->queue_key}_{$sess}_" ) . '%'; |
| 300 | |
| 301 | $rows = $wpdb->get_results( |
| 302 | $wpdb->prepare( |
| 303 | "SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", |
| 304 | $like |
| 305 | ), |
| 306 | ARRAY_A |
| 307 | ); |
| 308 | |
| 309 | $msgs = []; |
| 310 | foreach ( $rows as $r ) { |
| 311 | $msgs[] = maybe_unserialize( $r['option_value'] ); |
| 312 | delete_option( $r['option_name'] ); |
| 313 | } |
| 314 | usort( $msgs, fn( $a,$b ) => ( $a['id'] ?? 0 ) <=> ( $b['id'] ?? 0 ) ); |
| 315 | if ( $msgs ) $this->log( 'flush '.count($msgs).' msg(s)' ); |
| 316 | return $msgs; |
| 317 | } |
| 318 | #endregion |
| 319 | |
| 320 | #region Resources (note) |
| 321 | /*--------------------------------------------------*/ |
| 322 | /** |
| 323 | * MCP also supports “resources” – static or dynamic data a client can |
| 324 | * retrieve by URL (e.g. `mcp://resource/posts/123`). |
| 325 | */ |
| 326 | #endregion |
| 327 | } |
| 328 |