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() {
63 if let Ok(mut g) = CACHE.lock() {
64 *g = None;
65 }
66 super::pool::clear();
67}
68
69pub async fn get(cfg: &GatewayConfig) -> Catalog {
72 let ttl = Duration::from_secs(cfg.cache_ttl_secs);
73 if let Ok(guard) = CACHE.lock()
74 && let Some((at, cat)) = guard.as_ref()
75 && at.elapsed() < ttl
76 {
77 return cat.clone();
78 }
79 let fresh = build(cfg).await;
80 if let Ok(mut guard) = CACHE.lock() {
81 *guard = Some((Instant::now(), fresh.clone()));
82 }
83 fresh
84}
85
86pub async fn build(cfg: &GatewayConfig) -> Catalog {
88 let timeout = Duration::from_secs(cfg.call_timeout_secs.max(1));
89 let mut entries: Vec<CatalogEntry> = Vec::new();
90 let mut errors: Vec<String> = Vec::new();
91
92 for server in cfg.active_servers() {
93 if let Some(reason) = crate::core::addons::revocation::blocked_reason(&server.name) {
96 errors.push(format!("{}: revoked — {reason}", server.name));
97 continue;
98 }
99 let resolved = match server.resolve() {
100 Ok(r) => r,
101 Err(e) => {
102 errors.push(e);
103 continue;
104 }
105 };
106 match client::fetch_tools(&resolved, timeout).await {
107 Ok(tools) => {
108 for t in tools {
109 let tool = t.name.to_string();
110 entries.push(CatalogEntry {
111 namespaced: format!("{}::{}", server.name, tool),
112 server: server.name.clone(),
113 description: t.description.as_deref().unwrap_or("").trim().to_string(),
114 params: summarize_schema(t.input_schema.as_ref()),
115 tool,
116 });
117 }
118 }
119 Err(e) => errors.push(format!("{}: {e}", server.name)),
120 }
121 }
122
123 entries.sort_by(|a, b| a.namespaced.cmp(&b.namespaced));
125 entries.dedup_by(|a, b| a.namespaced == b.namespaced);
126 errors.sort();
127 errors.dedup();
128
129 Catalog { entries, errors }
130}
131
132fn summarize_schema(schema: &Map<String, Value>) -> String {
135 let Some(props) = schema.get("properties").and_then(Value::as_object) else {
136 return String::new();
137 };
138 let required: std::collections::HashSet<&str> = schema
139 .get("required")
140 .and_then(Value::as_array)
141 .map(|a| a.iter().filter_map(Value::as_str).collect())
142 .unwrap_or_default();
143 let mut names: Vec<String> = props
144 .keys()
145 .map(|k| {
146 if required.contains(k.as_str()) {
147 format!("{k}*")
148 } else {
149 k.clone()
150 }
151 })
152 .collect();
153 names.sort();
154 names.join(", ")
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use serde_json::json;
161
162 #[test]
163 fn split_namespaced_parses_handle() {
164 assert_eq!(split_namespaced("fs::read_file"), Some(("fs", "read_file")));
165 assert_eq!(split_namespaced("noseparator"), None);
166 assert_eq!(split_namespaced("::x"), None);
167 assert_eq!(split_namespaced("x::"), None);
168 }
169
170 #[test]
171 fn summarize_schema_marks_required() {
172 let schema = json!({
173 "type": "object",
174 "properties": { "path": {"type":"string"}, "depth": {"type":"integer"} },
175 "required": ["path"]
176 });
177 let s = summarize_schema(schema.as_object().unwrap());
178 assert_eq!(s, "depth, path*");
179 }
180
181 #[test]
182 fn summarize_schema_empty_when_no_props() {
183 let schema = json!({ "type": "object" });
184 assert_eq!(summarize_schema(schema.as_object().unwrap()), "");
185 }
186
187 #[tokio::test]
188 async fn revoked_server_is_dropped_from_catalog() {
189 let _iso = crate::core::data_dir::isolated_data_dir();
190 let mut list = crate::core::addons::revocation::RevocationList::load();
191 list.revoke("blocked", "kill-switch test", None);
192 list.save().expect("save");
193
194 let cfg = GatewayConfig {
195 enabled: true,
196 servers: vec![crate::core::gateway::GatewayServer {
197 name: "blocked".into(),
198 command: "true".into(),
199 enabled: true,
200 ..Default::default()
201 }],
202 ..Default::default()
203 };
204 let cat = build(&cfg).await;
205 assert!(cat.entries.is_empty());
207 assert!(
208 cat.errors.iter().any(|e| e.contains("revoked")),
209 "errors: {:?}",
210 cat.errors
211 );
212 }
213
214 #[test]
215 fn catalog_find_and_servers() {
216 let cat = Catalog {
217 entries: vec![
218 CatalogEntry {
219 server: "fs".into(),
220 tool: "read".into(),
221 namespaced: "fs::read".into(),
222 description: "Read a file".into(),
223 params: "path*".into(),
224 },
225 CatalogEntry {
226 server: "git".into(),
227 tool: "log".into(),
228 namespaced: "git::log".into(),
229 description: "Show log".into(),
230 params: String::new(),
231 },
232 ],
233 errors: vec![],
234 };
235 assert_eq!(cat.find("fs::read").unwrap().tool, "read");
236 assert!(cat.find("missing::x").is_none());
237 assert_eq!(cat.server_names(), vec!["fs", "git"]);
238 }
239}