Skip to main content

lean_ctx/core/gateway/
catalog.rs

1//! Aggregated downstream tool catalog (#210) with a TTL cache.
2//!
3//! Connects to every enabled downstream server, namespaces each tool as
4//! `server::tool`, and caches the union in-process for `cache_ttl_secs`. Fetch
5//! errors are *surfaced* (collected into [`Catalog::errors`]) rather than
6//! silently dropped, so a misconfigured server is visible to the agent.
7
8use std::sync::Mutex;
9use std::time::{Duration, Instant};
10
11use serde_json::{Map, Value};
12
13use super::client;
14use super::config::GatewayConfig;
15
16/// One downstream tool, namespaced by its server.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct CatalogEntry {
19    pub server: String,
20    pub tool: String,
21    /// `server::tool` — the stable, collision-free handle used by `ctx_tools`.
22    pub namespaced: String,
23    pub description: String,
24    /// Compact `param, required*` summary for ChoiceCards.
25    pub params: String,
26}
27
28/// The aggregated catalog plus any per-server fetch errors.
29#[derive(Debug, Clone, Default)]
30pub struct Catalog {
31    pub entries: Vec<CatalogEntry>,
32    pub errors: Vec<String>,
33}
34
35impl Catalog {
36    /// Look up an entry by its `server::tool` handle.
37    pub fn find(&self, namespaced: &str) -> Option<&CatalogEntry> {
38        self.entries.iter().find(|e| e.namespaced == namespaced)
39    }
40
41    /// Distinct server names present in the catalog, sorted.
42    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
50/// Split a `server::tool` handle back into its parts.
51pub 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
59/// Drop the cached catalog so the next [`get`] rebuilds it.
60pub fn invalidate() {
61    if let Ok(mut g) = CACHE.lock() {
62        *g = None;
63    }
64}
65
66/// Return the catalog, rebuilding it if the cache is empty or older than
67/// `cache_ttl_secs`.
68pub 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
83/// Build the catalog from scratch (no cache). Connects to each enabled server.
84pub 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        // Kill-switch (P2): a revoked server is dropped from the catalog so its
91        // tools cannot be discovered or called; the reason is surfaced.
92        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    // Stable order + collision-free dedup by handle.
121    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
129/// Render a JSON-Schema `properties`/`required` object into a compact
130/// `name, required*` parameter list (required params get a trailing `*`).
131fn 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        // Revoked server never spawned; it contributes no tools, only an error.
203        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}