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        if let Some((at, cat)) = guard.as_ref() {
72            if at.elapsed() < ttl {
73                return cat.clone();
74            }
75        }
76    }
77    let fresh = build(cfg).await;
78    if let Ok(mut guard) = CACHE.lock() {
79        *guard = Some((Instant::now(), fresh.clone()));
80    }
81    fresh
82}
83
84/// Build the catalog from scratch (no cache). Connects to each enabled server.
85pub async fn build(cfg: &GatewayConfig) -> Catalog {
86    let timeout = Duration::from_secs(cfg.call_timeout_secs.max(1));
87    let mut entries: Vec<CatalogEntry> = Vec::new();
88    let mut errors: Vec<String> = Vec::new();
89
90    for server in cfg.active_servers() {
91        let resolved = match server.resolve() {
92            Ok(r) => r,
93            Err(e) => {
94                errors.push(e);
95                continue;
96            }
97        };
98        match client::fetch_tools(&resolved, timeout).await {
99            Ok(tools) => {
100                for t in tools {
101                    let tool = t.name.to_string();
102                    entries.push(CatalogEntry {
103                        namespaced: format!("{}::{}", server.name, tool),
104                        server: server.name.clone(),
105                        description: t.description.as_deref().unwrap_or("").trim().to_string(),
106                        params: summarize_schema(t.input_schema.as_ref()),
107                        tool,
108                    });
109                }
110            }
111            Err(e) => errors.push(format!("{}: {e}", server.name)),
112        }
113    }
114
115    // Stable order + collision-free dedup by handle.
116    entries.sort_by(|a, b| a.namespaced.cmp(&b.namespaced));
117    entries.dedup_by(|a, b| a.namespaced == b.namespaced);
118    errors.sort();
119    errors.dedup();
120
121    Catalog { entries, errors }
122}
123
124/// Render a JSON-Schema `properties`/`required` object into a compact
125/// `name, required*` parameter list (required params get a trailing `*`).
126fn summarize_schema(schema: &Map<String, Value>) -> String {
127    let Some(props) = schema.get("properties").and_then(Value::as_object) else {
128        return String::new();
129    };
130    let required: std::collections::HashSet<&str> = schema
131        .get("required")
132        .and_then(Value::as_array)
133        .map(|a| a.iter().filter_map(Value::as_str).collect())
134        .unwrap_or_default();
135    let mut names: Vec<String> = props
136        .keys()
137        .map(|k| {
138            if required.contains(k.as_str()) {
139                format!("{k}*")
140            } else {
141                k.clone()
142            }
143        })
144        .collect();
145    names.sort();
146    names.join(", ")
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use serde_json::json;
153
154    #[test]
155    fn split_namespaced_parses_handle() {
156        assert_eq!(split_namespaced("fs::read_file"), Some(("fs", "read_file")));
157        assert_eq!(split_namespaced("noseparator"), None);
158        assert_eq!(split_namespaced("::x"), None);
159        assert_eq!(split_namespaced("x::"), None);
160    }
161
162    #[test]
163    fn summarize_schema_marks_required() {
164        let schema = json!({
165            "type": "object",
166            "properties": { "path": {"type":"string"}, "depth": {"type":"integer"} },
167            "required": ["path"]
168        });
169        let s = summarize_schema(schema.as_object().unwrap());
170        assert_eq!(s, "depth, path*");
171    }
172
173    #[test]
174    fn summarize_schema_empty_when_no_props() {
175        let schema = json!({ "type": "object" });
176        assert_eq!(summarize_schema(schema.as_object().unwrap()), "");
177    }
178
179    #[test]
180    fn catalog_find_and_servers() {
181        let cat = Catalog {
182            entries: vec![
183                CatalogEntry {
184                    server: "fs".into(),
185                    tool: "read".into(),
186                    namespaced: "fs::read".into(),
187                    description: "Read a file".into(),
188                    params: "path*".into(),
189                },
190                CatalogEntry {
191                    server: "git".into(),
192                    tool: "log".into(),
193                    namespaced: "git::log".into(),
194                    description: "Show log".into(),
195                    params: String::new(),
196                },
197            ],
198            errors: vec![],
199        };
200        assert_eq!(cat.find("fs::read").unwrap().tool, "read");
201        assert!(cat.find("missing::x").is_none());
202        assert_eq!(cat.server_names(), vec!["fs", "git"]);
203    }
204}