1use 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 pub cap_usd: f64,
31 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 budgets: Mutex<HashMap<(String, String), ToolBudget>>,
55 spend: Mutex<HashMap<(String, String), ToolSpend>>,
57 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 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 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 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 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 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"); 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}