lean_ctx/core/gateway/
catalog.rs1use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11use serde_json::{Map, Value};
12
13use super::client;
14use super::config::GatewayConfig;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct CatalogEntry {
19 pub server: String,
20 pub tool: String,
21 pub namespaced: String,
23 pub description: String,
24 pub params: String,
26}
27
28#[derive(Debug, Clone, Default)]
30pub struct Catalog {
31 pub entries: Vec<CatalogEntry>,
32 pub errors: Vec<String>,
33}
34
35impl Catalog {
36 pub fn find(&self, namespaced: &str) -> Option<&CatalogEntry> {
38 self.entries.iter().find(|e| e.namespaced == namespaced)
39 }
40
41 pub fn server_names(&self) -> Vec<String> {
43 let mut names: Vec<String> = self.entries.iter().map(|e| e.server.clone()).collect();
44 names.sort();
45 names.dedup();
46 names
47 }
48}
49
50pub fn split_namespaced(handle: &str) -> Option<(&str, &str)> {
52 handle
53 .split_once("::")
54 .filter(|(s, t)| !s.is_empty() && !t.is_empty())
55}
56
57static CACHE: Mutex<Option<(Instant, Catalog)>> = Mutex::new(None);
58
59pub fn invalidate() {
61 if let Ok(mut g) = CACHE.lock() {
62 *g = None;
63 }
64}
65
66pub async fn get(cfg: &GatewayConfig) -> Catalog {
69 let ttl = Duration::from_secs(cfg.cache_ttl_secs);
70 if let Ok(guard) = CACHE.lock()
71 && let Some((at, cat)) = guard.as_ref()
72 && at.elapsed() < ttl
73 {
74 return cat.clone();
75 }
76 let fresh = build(cfg).await;
77 if let Ok(mut guard) = CACHE.lock() {
78 *guard = Some((Instant::now(), fresh.clone()));
79 }
80 fresh
81}
82
83pub async fn build(cfg: &GatewayConfig) -> Catalog {
85 let timeout = Duration::from_secs(cfg.call_timeout_secs.max(1));
86 let mut entries: Vec<CatalogEntry> = Vec::new();
87 let mut errors: Vec<String> = Vec::new();
88
89 for server in cfg.active_servers() {
90 if let Some(reason) = crate::core::addons::revocation::blocked_reason(&server.name) {
93 errors.push(format!("{}: revoked — {reason}", server.name));
94 continue;
95 }
96 let resolved = match server.resolve() {
97 Ok(r) => r,
98 Err(e) => {
99 errors.push(e);
100 continue;
101 }
102 };
103 match client::fetch_tools(&resolved, timeout).await {
104 Ok(tools) => {
105 for t in tools {
106 let tool = t.name.to_string();
107 entries.push(CatalogEntry {
108 namespaced: format!("{}::{}", server.name, tool),
109 server: server.name.clone(),
110 description: t.description.as_deref().unwrap_or("").trim().to_string(),
111 params: summarize_schema(t.input_schema.as_ref()),
112 tool,
113 });
114 }
115 }
116 Err(e) => errors.push(format!("{}: {e}", server.name)),
117 }
118 }
119
120 entries.sort_by(|a, b| a.namespaced.cmp(&b.namespaced));
122 entries.dedup_by(|a, b| a.namespaced == b.namespaced);
123 errors.sort();
124 errors.dedup();
125
126 Catalog { entries, errors }
127}
128
129fn summarize_schema(schema: &Map<String, Value>) -> String {
132 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
133 return String::new();
134 };
135 let required: std::collections::HashSet<&str> = schema
136 .get("required")
137 .and_then(Value::as_array)
138 .map(|a| a.iter().filter_map(Value::as_str).collect())
139 .unwrap_or_default();
140 let mut names: Vec<String> = props
141 .keys()
142 .map(|k| {
143 if required.contains(k.as_str()) {
144 format!("{k}*")
145 } else {
146 k.clone()
147 }
148 })
149 .collect();
150 names.sort();
151 names.join(", ")
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157 use serde_json::json;
158
159 #[test]
160 fn split_namespaced_parses_handle() {
161 assert_eq!(split_namespaced("fs::read_file"), Some(("fs", "read_file")));
162 assert_eq!(split_namespaced("noseparator"), None);
163 assert_eq!(split_namespaced("::x"), None);
164 assert_eq!(split_namespaced("x::"), None);
165 }
166
167 #[test]
168 fn summarize_schema_marks_required() {
169 let schema = json!({
170 "type": "object",
171 "properties": { "path": {"type":"string"}, "depth": {"type":"integer"} },
172 "required": ["path"]
173 });
174 let s = summarize_schema(schema.as_object().unwrap());
175 assert_eq!(s, "depth, path*");
176 }
177
178 #[test]
179 fn summarize_schema_empty_when_no_props() {
180 let schema = json!({ "type": "object" });
181 assert_eq!(summarize_schema(schema.as_object().unwrap()), "");
182 }
183
184 #[tokio::test]
185 async fn revoked_server_is_dropped_from_catalog() {
186 let _iso = crate::core::data_dir::isolated_data_dir();
187 let mut list = crate::core::addons::revocation::RevocationList::load();
188 list.revoke("blocked", "kill-switch test", None);
189 list.save().expect("save");
190
191 let cfg = GatewayConfig {
192 enabled: true,
193 servers: vec![crate::core::gateway::GatewayServer {
194 name: "blocked".into(),
195 command: "true".into(),
196 enabled: true,
197 ..Default::default()
198 }],
199 ..Default::default()
200 };
201 let cat = build(&cfg).await;
202 assert!(cat.entries.is_empty());
204 assert!(
205 cat.errors.iter().any(|e| e.contains("revoked")),
206 "errors: {:?}",
207 cat.errors
208 );
209 }
210
211 #[test]
212 fn catalog_find_and_servers() {
213 let cat = Catalog {
214 entries: vec![
215 CatalogEntry {
216 server: "fs".into(),
217 tool: "read".into(),
218 namespaced: "fs::read".into(),
219 description: "Read a file".into(),
220 params: "path*".into(),
221 },
222 CatalogEntry {
223 server: "git".into(),
224 tool: "log".into(),
225 namespaced: "git::log".into(),
226 description: "Show log".into(),
227 params: String::new(),
228 },
229 ],
230 errors: vec![],
231 };
232 assert_eq!(cat.find("fs::read").unwrap().tool, "read");
233 assert!(cat.find("missing::x").is_none());
234 assert_eq!(cat.server_names(), vec!["fs", "git"]);
235 }
236}