Skip to main content

mcp_multiplexer/
stats.rs

1use crate::cache::{cache_dir, cache_path};
2use crate::model::ToolInfo;
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::path::PathBuf;
6use tokio::sync::Mutex;
7
8/// Usage counters for the `mcp-multiplexer --stats` report. Whole file
9/// rewritten on every change (it's a few hundred bytes); same tmp+rename
10/// pattern as tokens.json.
11/// ponytail: counters reset only if the file is deleted — no per-day history.
12#[derive(Debug, Default, Serialize, Deserialize)]
13pub struct StatsData {
14    /// Context cost the mux itself adds at startup (its own tool list, bytes).
15    #[serde(default)]
16    pub meta_bytes: u64,
17    /// Per meta-tool call counts.
18    #[serde(default)]
19    pub meta_calls: BTreeMap<String, u64>,
20    /// Schema bytes served on demand via search_tools/describe_tool.
21    #[serde(default)]
22    pub schema_bytes_served: u64,
23    /// Proxied upstream calls (call_tool + exposed routes).
24    #[serde(default)]
25    pub tool_calls_proxied: u64,
26}
27
28pub struct Stats {
29    data: Mutex<StatsData>,
30    path: PathBuf,
31}
32
33impl Stats {
34    pub fn new(meta_bytes: u64) -> std::sync::Arc<Stats> {
35        let path = cache_dir().join("stats.json");
36        let mut data: StatsData = std::fs::read_to_string(&path)
37            .ok()
38            .and_then(|t| serde_json::from_str(&t).ok())
39            .unwrap_or_default();
40        data.meta_bytes = meta_bytes;
41        let s = std::sync::Arc::new(Stats {
42            data: Mutex::new(data),
43            path,
44        });
45        s.persist_sync();
46        s
47    }
48
49    fn persist_sync(&self) {
50        let data = match self.data.try_lock() {
51            Ok(d) => d,
52            Err(_) => return, // a writer is mid-update; its save will cover us
53        };
54        if let Some(dir) = self.path.parent() {
55            let _ = std::fs::create_dir_all(dir);
56        }
57        let tmp = self.path.with_extension("json.tmp");
58        if let Ok(json) = serde_json::to_string(&*data) {
59            let _ = std::fs::write(&tmp, json);
60            let _ = std::fs::rename(&tmp, &self.path);
61        }
62    }
63
64    pub async fn record_meta(&self, tool: &str) {
65        {
66            let mut d = self.data.lock().await;
67            *d.meta_calls.entry(tool.to_string()).or_insert(0) += 1;
68        }
69        self.persist_sync();
70    }
71
72    pub async fn record_served(&self, schema_bytes: u64) {
73        {
74            let mut d = self.data.lock().await;
75            d.schema_bytes_served += schema_bytes;
76        }
77        self.persist_sync();
78    }
79
80    pub async fn record_proxied(&self) {
81        {
82            let mut d = self.data.lock().await;
83            d.tool_calls_proxied += 1;
84        }
85        self.persist_sync();
86    }
87}
88
89fn json_len<T: Serialize>(v: &T) -> u64 {
90    serde_json::to_string(v)
91        .map(|s| s.len() as u64)
92        .unwrap_or(0)
93}
94
95/// rtk-gain-style report. Reads the cached index (what a direct connection
96/// would inject) plus the counters file; works without a running mux.
97pub fn report() -> String {
98    let index_text = std::fs::read_to_string(cache_path()).ok();
99    let tools: Vec<ToolInfo> = index_text
100        .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
101        .and_then(|v| v.get("servers")?.as_object().cloned())
102        .map(|servers| {
103            servers
104                .values()
105                .filter_map(|ts| serde_json::from_value::<Vec<ToolInfo>>(ts.clone()).ok())
106                .flatten()
107                .collect()
108        })
109        .unwrap_or_default();
110    let stats: StatsData = std::fs::read_to_string(cache_dir().join("stats.json"))
111        .ok()
112        .and_then(|t| serde_json::from_str(&t).ok())
113        .unwrap_or_default();
114
115    let mut out = String::from("mcp-multiplexer stats\n\n");
116    if tools.is_empty() {
117        out.push_str("No cached index yet — start the mux once to build it.\n");
118    } else {
119        let withheld: u64 = tools.iter().map(json_len).sum();
120        let saved = withheld.saturating_sub(stats.meta_bytes);
121        let pct = (saved * 100).checked_div(withheld).unwrap_or(0);
122        out.push_str(&format!(
123            "Startup context per session:\n  without mux: ~{} tokens ({} tools)\n  with mux:    ~{} tokens (meta-tools)\n  saved:       ~{} tokens ({}%)\n",
124            withheld / 4,
125            tools.len(),
126            stats.meta_bytes / 4,
127            saved / 4,
128            pct
129        ));
130    }
131    let total_meta: u64 = stats.meta_calls.values().sum();
132    out.push_str(&format!(
133        "\nOn-demand schemas served: ~{} tokens\nTool calls proxied: {}\nMeta-tool calls: {}\n",
134        stats.schema_bytes_served / 4,
135        stats.tool_calls_proxied,
136        total_meta
137    ));
138    if total_meta > 0 {
139        for (name, n) in &stats.meta_calls {
140            out.push_str(&format!("  {name}: {n}\n"));
141        }
142    }
143    out.push_str("\nTokens ~ bytes/4 (heuristic, not a real tokenizer).\n");
144    out
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[tokio::test]
152    async fn stats_roundtrip_and_counters() {
153        let dir = std::env::temp_dir().join(format!("mcpmux-stats-{}", std::process::id()));
154        std::fs::create_dir_all(&dir).unwrap();
155        let path = dir.join("stats.json");
156        let s = Stats {
157            data: Mutex::new(StatsData::default()),
158            path: path.clone(),
159        };
160        s.record_meta("search_tools").await;
161        s.record_meta("search_tools").await;
162        s.record_served(500).await;
163        s.record_proxied().await;
164        let loaded: StatsData =
165            serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
166        assert_eq!(loaded.meta_calls["search_tools"], 2);
167        assert_eq!(loaded.schema_bytes_served, 500);
168        assert_eq!(loaded.tool_calls_proxied, 1);
169        std::fs::remove_dir_all(&dir).ok();
170    }
171}