How ai-memory Implements MCP Client Activity Tracking via Bounded UTC-Day Buckets

ai-memory tracks MCP tool call activity by buffering read/write counts into per-client, per-day buckets keyed by UTC day numbers, with automatic background flushing to SQLite and memory-bounded overflow handling.

The akitaonrails/ai-memory repository implements a lightweight yet robust activity tracking system for MCP (Model Context Protocol) clients. Rather than logging every individual request, the system aggregates metrics into bounded UTC-day buckets—providing daily granularity without unbounded memory growth. This article examines the complete implementation from buffer structure to persistent storage.


UTC-Day Bucket Foundation: US_PER_DAY

All time-based bucketing rests on a single foundational constant defined in the MCP server crate:

// crates/ai-memory-mcp/src/server.rs
const US_PER_DAY: i64 = 86_400_000_000; // microseconds in one UTC day

This constant converts any microsecond-precision timestamp into a day number via simple integer division. Using UTC eliminates timezone skew, ensuring that activity counts align to calendar days regardless of where clients or servers run.

let now_us = chrono::Utc::now().timestamp_micros();
let utc_day = now_us / US_PER_DAY; // bucket identifier: days since Unix epoch

ClientActivityBuffer: Bounded In-Memory Aggregation

The ClientActivityBuffer struct in crates/ai-memory-mcp/src/server.rs serves as the hot path for all activity recording:

pub struct ClientActivityBuffer {
    inner: Mutex<HashMap<(String, i64), (u32, u32)>>,
    // (client_name, utc_day) -> (read_count, write_count)
}

Key design decisions ensure bounded memory:

  • Per-day, per-client keys prevent cross-day pollution
  • u32 counters provide sufficient headroom while staying compact
  • Overflow handling caps distinct clients per day

Recording Activity with record and record_delta

Each MCP tool call triggers buffer.record(), which delegates to record_delta() for actual map manipulation:

// Simplified conceptual flow from crates/ai-memory-mcp/src/server.rs
impl ClientActivityBuffer {
    pub fn record(&self, client: String, day: i64, is_write: bool) -> bool {
        let mut map = self.inner.lock().unwrap();
        self.record_delta(&mut map, client, day, is_write)
    }
    
    fn record_delta(
        &self,
        map: &mut HashMap<(String, i64), (u32, u32)>,
        client: String,
        day: i64,
        is_write: bool
    ) -> bool {
        // Returns true if flusher should be started (first entry)
        // Handles CLIENT_ACTIVITY_OVERFLOW_CLIENT when limits exceeded
    }
}

The boolean return signals when the first entry enters an empty buffer—triggering the background flush loop initialization.


Memory Bounding via Client Limits

To prevent unbounded growth from malicious or misbehaving clients, the buffer enforces CLIENT_ACTIVITY_MAX_CLIENTS_PER_DAY. When exceeded, subsequent distinct client names collapse into a reserved overflow bucket:

const CLIENT_ACTIVITY_OVERFLOW_CLIENT: &str = "__overflow__";
// crates/ai-memory-mcp/src/server.rs lines 70-78

This guarantees that memory consumption scales with configured limits, not with arbitrary client cardinality. The overflow bucket preserves aggregate accuracy while sacrificing per-client granularity only for excess traffic.


Background Flush Loop: Periodic Persistence

A dedicated async task moves buffered data to durable storage without blocking request handlers:

// crates/ai-memory-mcp/src/server.rs lines 98-128
async fn flush_client_activity_loop(
    buffer: Arc<ClientActivityBuffer>,
    writer: WriterHandle,
    interval: Duration,
) {
    let mut ticker = tokio::time::interval(interval);
    loop {
        ticker.tick().await;
        
        let entries = buffer.take_entries(); // atomically extract
        if entries.is_empty() { continue; }
        
        match writer.bump_client_activity(entries).await {
            Ok(_) => {},
            Err(e) => {
                // Restore entries for retry; log and continue
                buffer.restore(entries);
            }
        }
    }
}

Key characteristics:

  • 60-second default interval (CLIENT_ACTIVITY_FLUSH) balances latency and I/O
  • Atomic take_entries() swaps in an empty map, minimizing lock contention
  • Failure recovery restores unwritten batches without data loss

WriterHandle::bump_client_activity to SQLite

The flush loop delegates to WriterHandle, defined in crates/ai-memory-store/src/writer.rs:

// crates/ai-memory-store/src/writer.rs lines 1051-1059
pub async fn bump_client_activity(
    &self,
    entries: Vec<((String, i64), (u32, u32))>,
) -> Result<(), StoreError> {
    let op = StoreOp::BumpClientActivity { entries };
    self.send(op).await
}

The actual SQL implementation resides in crates/ai-memory-store/src/ops.rs (around lines 1494-1506), performing an UPSERT against the client_activity table:

INSERT INTO client_activity (client, utc_day, reads, writes)
VALUES (?, ?, ?, ?)
ON CONFLICT(client, utc_day) DO UPDATE SET
    reads = reads + excluded.reads,
    writes = writes + excluded.writes;

This atomic accumulation ensures that concurrent flushes—or retries after failures—produce correct final counts.


Reading Aggregated Activity

The read path exposes daily-bucketed statistics via Store::client_activity in crates/ai-memory-store/src/reader.rs:

// crates/ai-memory-store/src/reader.rs lines 92-106
pub struct ClientActivity {
    pub client: String,
    pub reads: u64,
    pub writes: u64,
}

impl Store {
    pub async fn client_activity(&self, scope: Scope) -> Result<Vec<ClientActivity>, StoreError> {
        // Queries aggregated per-client, per-day rows
    }
}

Callers receive compact ClientActivity structs suitable for dashboards, rate limiting, or billing without processing raw request logs.


Complete Integration Example

The following pattern ties together recording, flushing, and retrieval:

use ai_memory_mcp::server::{ClientActivityBuffer, US_PER_DAY, CLIENT_ACTIVITY_FLUSH};
use ai_memory_store::Store;

// During MCP request handling
async fn handle_tool_call(
    buffer: Arc<ClientActivityBuffer>,
    client: String,
    tool: ToolCall,
) {
    let now_us = chrono::Utc::now().timestamp_micros();
    let utc_day = now_us / US_PER_DAY;
    let is_write = matches!(tool, ToolCall::Write(_));
    
    let should_spawn = buffer.record(client, utc_day, is_write);
    if should_spawn {
        let writer = /* obtain WriterHandle */;
        tokio::spawn(flush_client_activity_loop(
            buffer.clone(),
            writer,
            CLIENT_ACTIVITY_FLUSH,
        ));
    }
}

// Later, querying activity
async fn show_daily_stats(store: &Store, scope: Scope) -> Result<(), Box<dyn Error>> {
    let activities = store.client_activity(scope).await?;
    for a in activities {
        println!("{} | reads: {} | writes: {}", a.client, a.reads, a.writes);
    }
    Ok(())
}

Summary

  • US_PER_DAY (86,400,000,000 µs) converts timestamps to UTC day numbers for consistent bucketing
  • ClientActivityBuffer in server.rs holds HashMap<(client, day), (reads, writes)> with overflow protection
  • record / record_delta update counters and signal flusher initialization
  • CLIENT_ACTIVITY_MAX_CLIENTS_PER_DAY bounds memory; excess flows to __overflow__
  • flush_client_activity_loop runs every 60 seconds, atomically extracting and persisting batches
  • bump_client_activity in writer.rs delegates to SQLite UPSERTs in ops.rs
  • reader.rs surfaces aggregated ClientActivity for downstream consumption

Frequently Asked Questions

What happens when too many distinct clients hit the same UTC day?

Once CLIENT_ACTIVITY_MAX_CLIENTS_PER_DAY distinct client names are recorded, additional unique clients are aggregated under the reserved __overflow__ client name. This preserves total activity accuracy while capping memory usage. As implemented in crates/ai-memory-mcp/src/server.rs, the overflow bucket behaves identically to named clients for flushing and persistence.

Why use microsecond-precision UTC rather than system time or local time?

Microsecond-precision UTC guarantees that US_PER_DAY divisions yield consistent day boundaries regardless of server timezone configuration or daylight saving transitions. As defined at line 23 of server.rs, this eliminates clock skew issues in distributed deployments and ensures that "day" means the same calendar day globally.

Can the flush interval be tuned for lower latency?

The CLIENT_ACTIVITY_FLUSH constant (default 60 seconds) controls the tokio::time::interval in flush_client_activity_loop. While not exposed as runtime configuration in the current source, the modular structure in server.rs lines 98-128 allows direct parameter substitution for deployments requiring more aggressive persistence.

How does the system recover from failed flush attempts?

On WriterHandle::bump_client_activity failure, the flush loop invokes buffer.restore(entries) to return unwritten batches to the active buffer. The next interval iteration will retry the full batch. This at-least-once semantics ensures no activity is lost, though duplicate increments are possible if a failure occurs after SQLite commit but before acknowledgment—mitigated by the idempotent UPSERT design in ops.rs.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →