1use crate::value::VmDictExt;
25use std::cell::RefCell;
26use std::collections::BTreeMap;
27use std::sync::atomic::{AtomicU64, Ordering};
28use std::sync::Arc;
29use std::thread::LocalKey;
30
31use crate::value::{VmError, VmValue};
32
33#[derive(Clone, Debug)]
38pub(crate) struct CallBudget {
39 max: u64,
40 spent: Arc<AtomicU64>,
41}
42
43impl CallBudget {
44 fn new(max: u64) -> Self {
45 Self {
46 max,
47 spent: Arc::new(AtomicU64::new(0)),
48 }
49 }
50
51 fn spent(&self) -> u64 {
52 self.spent.load(Ordering::Relaxed)
53 }
54}
55
56thread_local! {
57 static MCP_CALL_BUDGET: RefCell<Option<CallBudget>> = const { RefCell::new(None) };
58 static PG_QUERY_BUDGET: RefCell<Option<CallBudget>> = const { RefCell::new(None) };
59}
60
61pub(crate) fn swap_mcp_call_budget(next: Option<CallBudget>) -> Option<CallBudget> {
64 MCP_CALL_BUDGET.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), next))
65}
66
67pub(crate) fn swap_pg_query_budget(next: Option<CallBudget>) -> Option<CallBudget> {
70 PG_QUERY_BUDGET.with(|slot| std::mem::replace(&mut *slot.borrow_mut(), next))
71}
72
73pub(crate) fn reset_call_budget_state() {
76 MCP_CALL_BUDGET.with(|b| *b.borrow_mut() = None);
77 PG_QUERY_BUDGET.with(|b| *b.borrow_mut() = None);
78}
79
80#[derive(Clone, Copy)]
85enum CallBudgetKind {
86 McpCalls,
87 PgQueries,
88}
89
90impl CallBudgetKind {
91 fn limit_label(self) -> &'static str {
93 match self {
94 CallBudgetKind::McpCalls => "mcp_calls",
95 CallBudgetKind::PgQueries => "pg_queries",
96 }
97 }
98
99 fn noun(self, plural: bool) -> &'static str {
102 match (self, plural) {
103 (CallBudgetKind::McpCalls, false) => "MCP call",
104 (CallBudgetKind::McpCalls, true) => "MCP calls",
105 (CallBudgetKind::PgQueries, false) => "Postgres query",
106 (CallBudgetKind::PgQueries, true) => "Postgres queries",
107 }
108 }
109}
110
111fn charge(
116 budget: &'static LocalKey<RefCell<Option<CallBudget>>>,
117 kind: CallBudgetKind,
118) -> Result<(), VmError> {
119 let Some(budget) = budget.with(|b| b.borrow().clone()) else {
120 return Ok(());
121 };
122 let spent = budget
123 .spent
124 .fetch_add(1, Ordering::Relaxed)
125 .saturating_add(1);
126 if spent > budget.max {
127 return Err(budget_exceeded_error(kind, spent, budget.max));
128 }
129 Ok(())
130}
131
132fn budget_exceeded_error(kind: CallBudgetKind, spent: u64, max: u64) -> VmError {
137 let mut dict = BTreeMap::new();
138 dict.put_str("category", "budget_exceeded");
139 dict.put_str("kind", "terminal");
140 dict.put_str("reason", "budget_exceeded");
141 dict.put_str("limit", kind.limit_label());
142 dict.insert("limit_value".to_string(), VmValue::Int(max as i64));
143 dict.insert("spent".to_string(), VmValue::Int(spent as i64));
144 dict.put_str(
145 "message",
146 format!(
147 "{} budget exceeded: this dispatch attempted {} of {} permitted {}",
148 kind.limit_label(),
149 spent,
150 max,
151 kind.noun(max != 1),
152 ),
153 );
154 VmError::Thrown(VmValue::dict(dict))
155}
156
157#[must_use = "dropping the guard immediately restores the prior MCP call budget"]
160pub struct McpCallBudgetGuard {
161 previous: Option<CallBudget>,
162}
163
164impl Drop for McpCallBudgetGuard {
165 fn drop(&mut self) {
166 MCP_CALL_BUDGET.with(|b| *b.borrow_mut() = self.previous.take());
167 }
168}
169
170pub fn install_mcp_call_budget(max: u64) -> McpCallBudgetGuard {
175 McpCallBudgetGuard {
176 previous: swap_mcp_call_budget(Some(CallBudget::new(max))),
177 }
178}
179
180pub fn charge_mcp_call() -> Result<(), VmError> {
183 charge(&MCP_CALL_BUDGET, CallBudgetKind::McpCalls)
184}
185
186pub fn mcp_calls_spent() -> Option<u64> {
190 MCP_CALL_BUDGET.with(|b| b.borrow().as_ref().map(CallBudget::spent))
191}
192
193#[must_use = "dropping the guard immediately restores the prior Postgres query budget"]
196pub struct PgQueryBudgetGuard {
197 previous: Option<CallBudget>,
198}
199
200impl Drop for PgQueryBudgetGuard {
201 fn drop(&mut self) {
202 PG_QUERY_BUDGET.with(|b| *b.borrow_mut() = self.previous.take());
203 }
204}
205
206pub fn install_pg_query_budget(max: u64) -> PgQueryBudgetGuard {
211 PgQueryBudgetGuard {
212 previous: swap_pg_query_budget(Some(CallBudget::new(max))),
213 }
214}
215
216pub fn charge_pg_query() -> Result<(), VmError> {
220 charge(&PG_QUERY_BUDGET, CallBudgetKind::PgQueries)
221}
222
223pub fn pg_queries_spent() -> Option<u64> {
226 PG_QUERY_BUDGET.with(|b| b.borrow().as_ref().map(CallBudget::spent))
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use crate::value::{error_to_category, ErrorCategory};
233
234 #[test]
235 fn charge_is_noop_without_installed_budget() {
236 reset_call_budget_state();
237 for _ in 0..1000 {
238 assert!(charge_mcp_call().is_ok());
239 assert!(charge_pg_query().is_ok());
240 }
241 assert_eq!(mcp_calls_spent(), None);
243 assert_eq!(pg_queries_spent(), None);
244 }
245
246 #[test]
247 fn mcp_budget_admits_up_to_ceiling_then_rejects() {
248 reset_call_budget_state();
249 let _guard = install_mcp_call_budget(2);
250 assert!(charge_mcp_call().is_ok());
251 assert!(charge_mcp_call().is_ok());
252 let third = charge_mcp_call();
253 let err = third.expect_err("third call must exceed mcp_calls: 2");
254 assert_eq!(error_to_category(&err), ErrorCategory::BudgetExceeded);
255 match &err {
256 VmError::Thrown(VmValue::Dict(d)) => {
257 assert_eq!(
258 d.get("limit").map(|v| v.display()).as_deref(),
259 Some("mcp_calls")
260 );
261 assert_eq!(d.get("limit_value").and_then(VmValue::as_int), Some(2));
262 assert_eq!(d.get("spent").and_then(VmValue::as_int), Some(3));
263 }
264 other => panic!("expected structured Thrown dict, got {other:?}"),
265 }
266 reset_call_budget_state();
267 }
268
269 #[test]
270 fn pg_budget_message_pluralises_and_names_dimension() {
271 reset_call_budget_state();
272 let _guard = install_pg_query_budget(1);
273 assert!(charge_pg_query().is_ok());
274 let err = charge_pg_query().expect_err("second query must exceed pg_queries: 1");
275 match &err {
276 VmError::Thrown(VmValue::Dict(d)) => {
277 let message = d.get("message").map(|v| v.display()).unwrap_or_default();
278 assert!(
279 message.contains("pg_queries budget exceeded"),
280 "got: {message}"
281 );
282 assert!(message.contains("Postgres query"), "got: {message}");
283 }
284 other => panic!("expected structured Thrown dict, got {other:?}"),
285 }
286 reset_call_budget_state();
287 }
288
289 #[test]
290 fn nested_guard_restores_outer_budget_and_count_on_drop() {
291 reset_call_budget_state();
292 let outer = install_mcp_call_budget(5);
293 assert!(charge_mcp_call().is_ok());
294 assert_eq!(mcp_calls_spent(), Some(1));
295
296 {
297 let _inner = install_mcp_call_budget(1);
299 assert_eq!(mcp_calls_spent(), Some(0));
300 assert!(charge_mcp_call().is_ok());
301 assert!(charge_mcp_call().is_err());
302 }
303
304 assert_eq!(
306 MCP_CALL_BUDGET.with(|b| b.borrow().as_ref().map(|b| b.max)),
307 Some(5)
308 );
309 assert_eq!(mcp_calls_spent(), Some(1));
310 drop(outer);
311 assert_eq!(mcp_calls_spent(), None);
312 reset_call_budget_state();
313 }
314}