table = $wpdb->prefix . 'aizen_logs'; add_filter('rest_request_after_callbacks', array($this, 'maybe_log_rest_call'), 10, 3); add_action('admin_post_aizen_clear_logs', array($this, 'handle_clear_logs')); } /** * Creates (or upgrades) the log table. Called on activation. */ public static function create_table() { global $wpdb; $table_name = $wpdb->prefix . 'aizen_logs'; $charset_collate = $wpdb->get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $sql = "CREATE TABLE {$table_name} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, created_at DATETIME NOT NULL, user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, user_login VARCHAR(60) NOT NULL DEFAULT '', ip VARCHAR(100) NOT NULL DEFAULT '', method VARCHAR(191) NOT NULL DEFAULT '', ability VARCHAR(191) NOT NULL DEFAULT '', status VARCHAR(20) NOT NULL DEFAULT '', message TEXT NULL, PRIMARY KEY (id), KEY created_at (created_at), KEY ability (ability), KEY status (status) ) {$charset_collate};"; dbDelta($sql); } /** * Inspects finished REST responses and, when they look like an MCP * JSON-RPC call, writes a row to the log. Read-only with respect to the * response - always returns $response untouched. * * @param WP_HTTP_Response|WP_Error $response Result to send. * @param array $handlers Registered route handlers (unused). * @param WP_REST_Request $request Request used to generate the response. * @return WP_HTTP_Response|WP_Error */ public function maybe_log_rest_call($response, $handlers, $request) { if (! ($request instanceof WP_REST_Request)) { return $response; } $route = $request->get_route(); // MCP Adapter servers live under a namespace/route the site owner // configures when the server is created (the default server is // mcp/mcp-adapter-default-server). Rather than hard-coding that, we // key off the JSON-RPC envelope every MCP HTTP call carries, which // is a stable signal regardless of which namespace a given server // was created with. $params = $request->get_json_params(); if (empty($params) || ! isset($params['jsonrpc'], $params['method'])) { return $response; } $method = sanitize_text_field($params['method']); $ability = ''; if (isset($params['params']['arguments']['ability_name'])) { $ability = sanitize_text_field($params['params']['arguments']['ability_name']); } else if (isset($params['params']['name'])) { $ability = sanitize_text_field($params['params']['name']); } $status = 'success'; $message = ''; if (is_wp_error($response)) { $status = 'error'; $message = $response->get_error_message(); } elseif ($response instanceof WP_HTTP_Response) { $data = $response->get_data(); if (is_array($data) && isset($data['error'])) { $status = 'error'; $message = is_array($data['error']) && isset($data['error']['message']) ? sanitize_text_field($data['error']['message']) : __('MCP call returned an error.', 'abilizen-ai-abilities'); } if ($response->get_status() >= 400) { $status = 'error'; } } if ($ability != '') { $this->log( array( 'route' => $route, 'method' => $method, 'ability' => $ability, 'status' => $status, 'message' => $message, ) ); } return $response; } /** * Writes a single row to the log table and trims old rows. * * @param array $data { * @type string $method JSON-RPC method or internal event name. * @type string $ability Ability/tool name, if any. * @type string $status 'success' or 'error'. * @type string $message Optional human-readable detail. * } */ public function log(array $data) { global $wpdb; $user = wp_get_current_user(); $wpdb->insert( $this->table, array( 'created_at' => current_time('mysql', true), 'user_id' => $user && $user->ID ? $user->ID : 0, 'user_login' => $user && $user->ID ? $user->user_login : __('unauthenticated', 'abilizen-ai-abilities'), 'ip' => $this->get_ip(), 'method' => isset($data['method']) ? substr($data['method'], 0, 191) : '', 'ability' => isset($data['ability']) ? substr($data['ability'], 0, 191) : '', 'status' => isset($data['status']) ? substr($data['status'], 0, 20) : 'success', 'message' => isset($data['message']) ? wp_strip_all_tags($data['message']) : '', ), array('%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s') ); $this->trim_old_rows(); } /** * Keeps the table from growing forever. */ protected function trim_old_rows() { global $wpdb; $count = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table}"); if ($count > AIZEN_LOG_RETENTION) { $excess = $count - AIZEN_LOG_RETENTION; $wpdb->query( $wpdb->prepare( "DELETE FROM {$this->table} ORDER BY id ASC LIMIT %d", $excess ) ); } } /** * @return string */ protected function get_ip() { if (! empty($_SERVER['REMOTE_ADDR'])) { return sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])); } return ''; } /** * Fetches the most recent log rows for the dashboard, newest first. * * @param int $limit Max rows. * @param int $offset Offset for pagination. * @return array */ public function get_recent(int $limit = 50, int $offset = 0): array { global $wpdb; $limit = max(1, min(200, $limit)); $offset = max(0, $offset); return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$this->table} ORDER BY id DESC LIMIT %d OFFSET %d", $limit, $offset ) ); } /** * @return int */ public function count_total(): int { global $wpdb; return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$this->table}"); } /** * @return int */ public function count_errors(): int { global $wpdb; return (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$this->table} WHERE status = %s", 'error')); } /** * Handles the "Clear log" button. */ public function handle_clear_logs() { if (! current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'abilizen-ai-abilities')); } check_admin_referer('aizen_clear_logs'); global $wpdb; $wpdb->query("TRUNCATE TABLE {$this->table}"); wp_safe_redirect(add_query_arg(array('page' => 'ai-zen', 'tab' => 'logging', 'cleared' => '1'), admin_url('admin.php'))); exit; } }