Skip to main content

tokenmiser_mcp/
lib.rs

1//! MCP gateway enforcing per-(agent, tool) budget caps.
2//!
3//! Speaks the JSON-RPC `tools/call` subset over HTTP only — not the full MCP
4//! server spec, and no stdio transport.
5
6use std::collections::HashMap;
7use std::sync::Arc;
8
9use parking_lot::Mutex;
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12use tracing::warn;
13
14#[derive(Debug, Error)]
15pub enum BudgetError {
16    #[error(
17        "budget exceeded for agent={agent} tool={tool}: spent ${spent_usd:.4} of ${cap_usd:.4}"
18    )]
19    Exceeded {
20        agent: String,
21        tool: String,
22        spent_usd: f64,
23        cap_usd: f64,
24    },
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ToolBudget {
29    /// Maximum USD this (agent, tool) tuple may spend in this window.
30    pub cap_usd: f64,
31    /// Window length in seconds; 0 means lifetime.
32    pub window_secs: u64,
33}
34
35impl Default for ToolBudget {
36    fn default() -> Self {
37        Self {
38            cap_usd: 1.0,
39            window_secs: 3600,
40        }
41    }
42}
43
44#[derive(Debug, Clone, Default, Serialize, Deserialize)]
45pub struct ToolSpend {
46    pub spent_usd: f64,
47    pub calls: u64,
48    pub denied: u64,
49    pub window_started_at_unix: i64,
50}
51
52pub struct McpBudgetGateway {
53    /// Per-(agent, tool) budget config.
54    budgets: Mutex<HashMap<(String, String), ToolBudget>>,
55    /// Per-(agent, tool) current spend.
56    spend: Mutex<HashMap<(String, String), ToolSpend>>,
57    /// Applied to any (agent, tool) without an explicit config.
58    default_budget: ToolBudget,
59}
60
61impl McpBudgetGateway {
62    pub fn new(default_budget: ToolBudget) -> Arc<Self> {
63        Arc::new(Self {
64            budgets: Mutex::new(HashMap::new()),
65            spend: Mutex::new(HashMap::new()),
66            default_budget,
67        })
68    }
69
70    pub fn set_budget(&self, agent: &str, tool: &str, b: ToolBudget) {
71        self.budgets
72            .lock()
73            .insert((agent.to_string(), tool.to_string()), b);
74    }
75
76    /// Check whether a call is allowed, returning the current spend snapshot
77    /// or a `BudgetError` when the cap is breached.
78    pub fn check(&self, agent: &str, tool: &str) -> Result<ToolSpend, BudgetError> {
79        let key = (agent.to_string(), tool.to_string());
80        let budget = self
81            .budgets
82            .lock()
83            .get(&key)
84            .cloned()
85            .unwrap_or_else(|| self.default_budget.clone());
86
87        let mut spend_map = self.spend.lock();
88        let now = now_unix();
89        let spend = spend_map.entry(key.clone()).or_default();
90
91        // Window rollover.
92        if budget.window_secs > 0
93            && spend.window_started_at_unix > 0
94            && (now - spend.window_started_at_unix) as u64 >= budget.window_secs
95        {
96            *spend = ToolSpend {
97                window_started_at_unix: now,
98                ..Default::default()
99            };
100        }
101        if spend.window_started_at_unix == 0 {
102            spend.window_started_at_unix = now;
103        }
104
105        if spend.spent_usd >= budget.cap_usd {
106            spend.denied += 1;
107            let denied = spend.clone();
108            warn!(
109                agent = %agent,
110                tool = %tool,
111                spent = spend.spent_usd,
112                cap = budget.cap_usd,
113                "MCP budget denied"
114            );
115            return Err(BudgetError::Exceeded {
116                agent: agent.into(),
117                tool: tool.into(),
118                spent_usd: denied.spent_usd,
119                cap_usd: budget.cap_usd,
120            });
121        }
122        Ok(spend.clone())
123    }
124
125    /// Record the cost of a completed tool call, after the upstream returns.
126    pub fn record(&self, agent: &str, tool: &str, cost_usd: f64) {
127        let key = (agent.to_string(), tool.to_string());
128        let mut spend_map = self.spend.lock();
129        let spend = spend_map.entry(key).or_default();
130        if spend.window_started_at_unix == 0 {
131            spend.window_started_at_unix = now_unix();
132        }
133        spend.spent_usd += cost_usd;
134        spend.calls += 1;
135    }
136
137    pub fn snapshot(&self) -> HashMap<String, ToolSpend> {
138        self.spend
139            .lock()
140            .iter()
141            .map(|((agent, tool), s)| (format!("{agent}::{tool}"), s.clone()))
142            .collect()
143    }
144}
145
146fn now_unix() -> i64 {
147    std::time::SystemTime::now()
148        .duration_since(std::time::UNIX_EPOCH)
149        .map(|d| d.as_secs() as i64)
150        .unwrap_or(0)
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn check_allows_until_cap() {
159        let g = McpBudgetGateway::new(ToolBudget {
160            cap_usd: 0.10,
161            window_secs: 3600,
162        });
163        for _ in 0..5 {
164            g.check("agent-a", "search").unwrap();
165            g.record("agent-a", "search", 0.02);
166        }
167        // Spend is now at the cap.
168        assert!(g.check("agent-a", "search").is_err());
169    }
170
171    #[test]
172    fn explicit_budget_overrides_default() {
173        let g = McpBudgetGateway::new(ToolBudget {
174            cap_usd: 100.0,
175            window_secs: 3600,
176        });
177        g.set_budget(
178            "agent-a",
179            "expensive_tool",
180            ToolBudget {
181                cap_usd: 0.05,
182                window_secs: 3600,
183            },
184        );
185        g.check("agent-a", "expensive_tool").unwrap();
186        g.record("agent-a", "expensive_tool", 0.06);
187        assert!(g.check("agent-a", "expensive_tool").is_err());
188        // A different tool is still allowed under the default budget.
189        g.check("agent-a", "cheap_tool").unwrap();
190    }
191
192    #[test]
193    fn snapshot_includes_calls_and_denials() {
194        let g = McpBudgetGateway::new(ToolBudget {
195            cap_usd: 0.01,
196            window_secs: 3600,
197        });
198        g.check("a", "t").unwrap();
199        g.record("a", "t", 0.02);
200        let _ = g.check("a", "t"); // denied
201        let snap = g.snapshot();
202        let entry = snap.get("a::t").unwrap();
203        assert_eq!(entry.calls, 1);
204        assert_eq!(entry.denied, 1);
205        assert!((entry.spent_usd - 0.02).abs() < 1e-9);
206    }
207}