prefix . 'mwai_logs'; if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) !== $table ) { return; } $from = gmdate( 'Y-m-d 00:00:00', time() - self::KEEP_DAYS * DAY_IN_SECONDS ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT DATE(time) AS day, scope, stats FROM $table WHERE feature = 'mcp_tool' AND time >= %s", $from ), ARRAY_A ); if ( empty( $rows ) ) { return; } $writes = []; $built = []; foreach ( $rows as $row ) { $tool = (string) $row['scope']; if ( $tool === '' || $tool === 'mcp_ping' ) { continue; } $stats = json_decode( (string) $row['stats'], true ); $stats = is_array( $stats ) ? $stats : []; if ( !isset( $writes[ $tool ] ) ) { $writes[ $tool ] = self::$mcp && method_exists( self::$mcp, 'is_write_call' ) && self::$mcp->is_write_call( $tool ); } $day = $row['day']; $entry = $built[ $day ] ?? [ 'c' => 0, 'e' => 0, 'w' => 0, 't' => [], 'a' => [] ]; // A call that changed nothing (the same featured image set again) is not a change. if ( $writes && stripos( (string) wp_json_encode( $event['result'] ?? '' ), 'already set' ) !== false ) { $writes = false; } $entry['c']++; if ( ( $stats['status'] ?? 'success' ) !== 'success' ) { $entry['e']++; } elseif ( $writes[ $tool ] ) { $entry['w']++; } $entry['t'][ $tool ] = ( $entry['t'][ $tool ] ?? 0 ) + 1; $client = !empty( $stats['client_name'] ) ? (string) $stats['client_name'] : ( ( $stats['auth_method'] ?? '' ) === 'oauth' ? 'OAuth app' : 'Bearer token' ); $entry['a'][ $client ] = ( $entry['a'][ $client ] ?? 0 ) + 1; $built[ $day ] = $entry; } // A day the live counter already saw keeps its own changes, which also know the arguments. $usage = get_option( self::OPTION, [] ); $usage = is_array( $usage ) ? $usage : []; foreach ( $built as $day => $entry ) { if ( isset( $usage[ $day ] ) ) { if ( $entry['c'] <= $usage[ $day ]['c'] ) { continue; } $entry['w'] = max( $entry['w'], (int) $usage[ $day ]['w'] ); } $usage[ $day ] = $entry; } ksort( $usage ); update_option( self::OPTION, $usage, false ); } public function record( $event ) { $tool = is_array( $event ) ? (string) ( $event['tool'] ?? '' ) : ''; if ( $tool === '' || $tool === 'mcp_ping' ) { return; } $usage = get_option( self::OPTION, [] ); if ( !is_array( $usage ) ) { $usage = []; } $day = gmdate( 'Y-m-d' ); $entry = $usage[ $day ] ?? [ 'c' => 0, 'e' => 0, 'w' => 0, 't' => [], 'a' => [] ]; $status = $event['status'] ?? ''; // 'writes' comes from labs/mcp.php; events from older code only carry the access level. $writes = isset( $event['writes'] ) ? (bool) $event['writes'] : ( !empty( $event['access_level'] ) && $event['access_level'] !== 'read' ); $entry['c']++; if ( $status !== 'success' ) { $entry['e']++; } elseif ( $writes ) { $entry['w']++; } $entry['t'][ $tool ] = ( $entry['t'][ $tool ] ?? 0 ) + 1; $client = self::client_of( $event ); $entry['a'][ $client ] = ( $entry['a'][ $client ] ?? 0 ) + 1; $usage[ $day ] = $entry; $cutoff = gmdate( 'Y-m-d', time() - self::KEEP_DAYS * DAY_IN_SECONDS ); foreach ( array_keys( $usage ) as $key ) { if ( $key < $cutoff ) { unset( $usage[ $key ] ); } } update_option( self::OPTION, $usage, false ); if ( $status === 'success' && $writes ) { $this->remember_change( $tool, $client, is_array( $event['args'] ?? null ) ? $event['args'] : [] ); } } // OAuth apps name themselves. A token call has no name, but developer tools say who they are // in their user agent: "Claude Code (token)" tells more than a key. The tab reads the suffix. private static function client_of( array $event ): string { if ( !empty( $event['client_name'] ) ) { return (string) $event['client_name']; } if ( ( $event['auth_method'] ?? '' ) === 'oauth' ) { return 'OAuth app'; } $agent = (string) ( $_SERVER['HTTP_USER_AGENT'] ?? '' ); $known = [ '/^claude-code/i' => 'Claude Code', '/codex/i' => 'Codex', '/cursor/i' => 'Cursor', '/gemini/i' => 'Gemini CLI' ]; foreach ( $known as $pattern => $name ) { if ( preg_match( $pattern, $agent ) ) { return $name . ' (token)'; } } return 'Bearer token'; } // The last changes an assistant made, so the MCP tab can say what happened and link to it. // Only the tool, who, when and which post: never the content or the arguments. private function remember_change( string $tool, string $client, array $args ) { $id = 0; foreach ( [ 'ID', 'id', 'post_id', 'postId', 'attachment_id', 'comment_id' ] as $key ) { if ( !empty( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) { $id = (int) $args[ $key ]; break; } } $isComment = isset( $args['comment_id'] ) || strpos( $tool, 'comment' ) !== false; // A comment points to the post it belongs to, so the tab can say "Commented on ". if ( $isComment ) { $id = !empty( $args['post_id'] ) && is_numeric( $args['post_id'] ) ? (int) $args['post_id'] : 0; if ( !$id && !empty( $args['comment_id'] ) && ( $comment = get_comment( (int) $args['comment_id'] ) ) ) { $id = (int) $comment->comment_post_ID; } } $title = ''; if ( $id && get_post( $id ) ) { $title = wp_strip_all_tags( get_the_title( $id ) ); } // A new post, term or file has no ID in its arguments yet: its name tells which one it was. if ( !$title && !$isComment ) { $title = (string) ( $args['post_title'] ?? $args['title'] ?? $args['name'] ?? '' ); if ( $title === '' && !empty( $args['url'] ) && is_string( $args['url'] ) ) { $title = rawurldecode( wp_basename( (string) wp_parse_url( $args['url'], PHP_URL_PATH ) ) ); } $title = wp_strip_all_tags( $title ); } $title = html_entity_decode( $title, ENT_QUOTES, 'UTF-8' ); $removed = !empty( $args['remove'] ) || ( isset( $args['terms'] ) && $args['terms'] === [] && isset( $args['append'] ) && !$args['append'] ); $recent = get_option( self::RECENT_OPTION, [] ); $recent = is_array( $recent ) ? $recent : []; // An assistant often edits the same post several times in a row: one row, with a count. $last = $recent[0] ?? null; if ( $last && self::same_change( $last, $tool, $client, $id, $title ) && !empty( $last['removed'] ) === $removed && time() - (int) $last['time'] < 15 * MINUTE_IN_SECONDS ) { $recent[0]['time'] = time(); $recent[0]['count'] = (int) ( $last['count'] ?? 1 ) + 1; update_option( self::RECENT_OPTION, $recent, false ); return; } array_unshift( $recent, [ 'time' => time(), 'tool' => $tool, 'client' => $client, 'id' => $id, 'title' => mb_substr( $title, 0, 120 ), 'taxonomy' => isset( $args['taxonomy'] ) ? sanitize_key( (string) $args['taxonomy'] ) : '', // Taking terms off (or clearing them) must not read as "Tagged". 'removed' => $removed, ] ); update_option( self::RECENT_OPTION, array_slice( $recent, 0, self::RECENT_MAX ), false ); } // WordPress's own before/after view, for edits that touch the content. Only when a revision // was saved at that moment: a fields-only change makes none, and an older one would mislead. private static function diff_url( WP_Post $post, string $tool, int $time ): ?string { if ( !preg_match( '/^(wp_update_post|wp_alter_post|wp_write_blocks|wc_update_product|wc_alter_product)$/', $tool ) ) { return null; } if ( !post_type_supports( $post->post_type, 'revisions' ) ) { return null; } $revisions = wp_get_post_revisions( $post->ID, [ 'numberposts' => 1 ] ); $revision = reset( $revisions ); if ( !$revision || abs( strtotime( $revision->post_date_gmt . ' UTC' ) - $time ) > 5 * MINUTE_IN_SECONDS ) { return null; } return admin_url( 'revision.php?revision=' . $revision->ID ); } private static function same_change( array $row, string $tool, string $client, int $id, string $title ): bool { return ( $row['tool'] ?? '' ) === $tool && ( $row['client'] ?? '' ) === $client && (int) ( $row['id'] ?? 0 ) === $id && ( $row['title'] ?? '' ) === $title; } public static function recent(): array { $recent = get_option( self::RECENT_OPTION, [] ); $out = []; foreach ( is_array( $recent ) ? $recent : [] as $item ) { $id = (int) ( $item['id'] ?? 0 ); $post = $id ? get_post( $id ) : null; $row = [ 'time' => (int) ( $item['time'] ?? 0 ), 'tool' => (string) ( $item['tool'] ?? '' ), 'client' => (string) ( $item['client'] ?? '' ), // Older rows were stored texturized ("l’envers"). 'title' => html_entity_decode( (string) ( $item['title'] ?? '' ), ENT_QUOTES, 'UTF-8' ), 'count' => max( 1, (int) ( $item['count'] ?? 1 ) ), 'taxonomy' => (string) ( $item['taxonomy'] ?? '' ), 'removed' => !empty( $item['removed'] ), 'editUrl' => $post ? get_edit_post_link( $id, 'raw' ) : null, 'diffUrl' => $post ? self::diff_url( $post, (string) ( $item['tool'] ?? '' ), (int) ( $item['time'] ?? 0 ) ) : null, 'thumb' => $post ? ( get_the_post_thumbnail_url( $id, 'thumbnail' ) ?: ( $post->post_type === 'attachment' && wp_attachment_is_image( $id ) ? wp_get_attachment_image_url( $id, 'thumbnail' ) : null ) ) : null, ]; // Rows from before the merge above: fold consecutive repeats here too. $prev = count( $out ) - 1; if ( $prev >= 0 && $out[ $prev ]['tool'] === $row['tool'] && $out[ $prev ]['client'] === $row['client'] && $out[ $prev ]['title'] === $row['title'] && $out[ $prev ]['editUrl'] === $row['editUrl'] && $out[ $prev ]['removed'] === $row['removed'] && $out[ $prev ]['time'] - $row['time'] < 15 * MINUTE_IN_SECONDS ) { $out[ $prev ]['count'] += $row['count']; continue; } $out[] = $row; } return $out; } public static function summary( int $days = 7 ): array { $days = max( 1, min( 30, $days ) ); self::maybe_backfill(); $usage = get_option( self::OPTION, [] ); $usage = is_array( $usage ) ? $usage : []; $perDay = []; $totals = [ 'calls' => 0, 'errors' => 0, 'changes' => 0 ]; $tools = []; $assistants = []; for ( $i = $days - 1; $i >= 0; $i-- ) { $date = gmdate( 'Y-m-d', time() - $i * DAY_IN_SECONDS ); $entry = $usage[ $date ] ?? null; $perDay[] = [ 'date' => $date, 'calls' => (int) ( $entry['c'] ?? 0 ) ]; if ( !$entry ) { continue; } $totals['calls'] += (int) $entry['c']; $totals['errors'] += (int) $entry['e']; $totals['changes'] += (int) $entry['w']; foreach ( (array) ( $entry['t'] ?? [] ) as $name => $count ) { $tools[ $name ] = ( $tools[ $name ] ?? 0 ) + (int) $count; } foreach ( (array) ( $entry['a'] ?? [] ) as $name => $count ) { $assistants[ $name ] = ( $assistants[ $name ] ?? 0 ) + (int) $count; } } arsort( $tools ); arsort( $assistants ); $top = []; foreach ( array_slice( $tools, 0, 5, true ) as $name => $count ) { $top[] = [ 'tool' => $name, 'calls' => $count ]; } $byAssistant = []; foreach ( $assistants as $name => $count ) { $byAssistant[] = [ 'name' => $name, 'calls' => $count ]; } // The first day ever counted, so the tab can say a quiet chart is new, not empty. $since = $usage ? min( array_keys( $usage ) ) : null; return array_merge( $totals, [ 'days' => $days, 'since' => $since, 'assistants' => $byAssistant, 'perDay' => $perDay, 'topTools' => $top, ] ); } }