featherbit/mcp/tools/
cache.rs1use schemars::JsonSchema;
4use serde::Deserialize;
5use serde_json::Value;
6
7use super::ToolError;
8use crate::state::SharedState;
9use crate::traffic::{collect_targets, purge_targets};
10
11#[derive(Debug, Deserialize, JsonSchema)]
12pub struct PurgeCacheArgs {
13 pub id: String,
15 #[serde(default)]
17 pub dry_run: bool,
18}
19
20pub async fn purge_cache(state: &SharedState, a: PurgeCacheArgs) -> Result<Value, ToolError> {
25 let graphs: Vec<_> = state
26 .routes
27 .read()
28 .await
29 .iter()
30 .map(|(_, g)| g.clone())
31 .collect();
32 let targets = collect_targets(&graphs, &a.id);
33 if targets.is_empty() {
34 return Err(ToolError::not_found("proxy-cache pair", &a.id));
35 }
36
37 if a.dry_run {
38 let would: Vec<Value> = targets
39 .iter()
40 .map(|t| {
41 let mut v = serde_json::json!({ "backend": t.backend_label });
42 if !t.store.is_empty() {
43 v["store"] = Value::String(t.store.clone());
44 }
45 v
46 })
47 .collect();
48 return Ok(serde_json::json!({ "id": a.id, "dry_run": true, "purged": would }));
49 }
50
51 match purge_targets(&targets).await {
52 Ok(purged) => Ok(serde_json::json!({ "id": a.id, "purged": purged })),
53 Err((purged, e)) => {
54 let mut err = ToolError::new(
55 "cache_purge_failed",
56 format!("purging pair '{}' failed on a backend", a.id),
57 );
58 err.errors = vec![e.to_string()];
59 err.hint = Some(format!(
60 "{} backend(s) were purged before the failure",
61 purged.len()
62 ));
63 Err(err)
64 }
65 }
66}
67
68#[cfg(test)]
69mod tests {
70 use std::sync::Arc;
71
72 use crate::mcp::tools::call;
73 use crate::mcp::tools::test_support::{obj, state};
74 use crate::state::SharedState;
75
76 const LOCAL_PAIR_GATEWAY: &str = r#"
77routes:
78 - name: products
79 match: { path: /products }
80 policy: products-policy
81policies:
82 - name: products-policy
83 nodes:
84 - { id: listener, type: listener, config: {} }
85 - { id: look, type: proxy-cache, config: { phase: lookup, id: products, policy: local } }
86 - { id: up, type: upstream, config: { targets: [{ host: h, port: 80 }] } }
87 - { id: keep, type: proxy-cache, config: { phase: store, id: products, policy: local } }
88 - { id: client, type: client, config: {} }
89 edges:
90 - { from: listener.out, to: look.in }
91 - { from: look.success, to: up.in }
92 - { from: look.hit, to: client.in }
93 - { from: up.success, to: keep.in }
94 - { from: keep.success, to: client.in }
95 - { from: keep.hit, to: client.in }
96"#;
97
98 async fn state_with_local_cache_pair() -> Arc<SharedState> {
99 state("{}", LOCAL_PAIR_GATEWAY)
100 }
101
102 async fn backend(s: &SharedState) -> Arc<dyn crate::traffic::ResponseCache> {
105 let graphs: Vec<_> = s
106 .routes
107 .read()
108 .await
109 .iter()
110 .map(|(_, g)| g.clone())
111 .collect();
112 crate::traffic::collect_targets(&graphs, "products")
113 .remove(0)
114 .backend
115 }
116
117 async fn seed(s: &SharedState, key: &str) {
118 let entry = crate::traffic::CachedResponse {
119 status: 200,
120 headers: Default::default(),
121 body: bytes::Bytes::from_static(b"x"),
122 };
123 backend(s)
124 .await
125 .put(key, &entry, std::time::Duration::from_secs(60))
126 .await
127 .unwrap();
128 }
129
130 async fn still_cached(s: &SharedState, key: &str) -> bool {
131 backend(s).await.get(key).await.unwrap().is_some()
132 }
133
134 #[tokio::test]
137 async fn purge_cache_dry_run_lists_targets_and_removes_nothing() {
138 let s = state_with_local_cache_pair().await;
139 seed(&s, "products\u{1}/x").await;
140
141 let v = call(
142 &s,
143 "purge_cache",
144 obj(serde_json::json!({ "id": "products", "dry_run": true })),
145 )
146 .await
147 .unwrap();
148
149 assert_eq!(v["purged"].as_array().unwrap().len(), 1);
150 assert!(
151 v["purged"][0].get("removed").is_none(),
152 "dry_run must not report a count: {v}"
153 );
154 assert!(
155 still_cached(&s, "products\u{1}/x").await,
156 "dry_run must not delete"
157 );
158 }
159
160 #[tokio::test]
161 async fn purge_cache_removes_and_counts() {
162 let s = state_with_local_cache_pair().await;
163 seed(&s, "products\u{1}/x").await;
164
165 let v = call(
166 &s,
167 "purge_cache",
168 obj(serde_json::json!({ "id": "products" })),
169 )
170 .await
171 .unwrap();
172
173 assert_eq!(v["purged"][0]["removed"], 1);
174 assert!(!still_cached(&s, "products\u{1}/x").await);
175 }
176
177 #[tokio::test]
179 async fn purge_cache_unknown_id_is_not_found() {
180 let s = state_with_local_cache_pair().await;
181 let err = call(
182 &s,
183 "purge_cache",
184 obj(serde_json::json!({ "id": "prodcuts" })),
185 )
186 .await
187 .unwrap_err();
188 assert_eq!(err.code, "not_found");
189 assert!(err.message.contains("prodcuts"), "{}", err.message);
190 }
191}