1use std::collections::HashMap;
33use std::fs;
34use std::path::PathBuf;
35use std::time::Duration;
36
37use serde::{Deserialize, Serialize};
38
39use crate::Paths;
40use crate::error::{Error, Result};
41
42pub const DEFAULT_SOURCE_URL: &str =
45 "https://raw.githubusercontent.com/modelcontextprotocol/servers/main/.well-known/registry.json";
46
47const FETCH_TIMEOUT_SECS: u64 = 10;
48const USER_AGENT: &str = concat!("agentsec-registry/", env!("CARGO_PKG_VERSION"));
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct RegistryEntry {
53 pub name: String,
56 pub source: String,
60}
61
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
65pub struct Registry {
66 pub entries: HashMap<String, RegistryEntry>,
68}
69
70impl Registry {
71 pub fn builtin() -> Self {
74 const BUILTIN: &[(&str, &str)] = &[
75 ("filesystem", "modelcontextprotocol/servers"),
76 ("github", "modelcontextprotocol/servers"),
77 ("gitlab", "modelcontextprotocol/servers"),
78 ("git", "modelcontextprotocol/servers"),
79 ("memory", "modelcontextprotocol/servers"),
80 ("fetch", "modelcontextprotocol/servers"),
81 ("puppeteer", "modelcontextprotocol/servers"),
82 ("sequential-thinking", "modelcontextprotocol/servers"),
83 ("everything", "modelcontextprotocol/servers"),
84 ("brave-search", "modelcontextprotocol/servers"),
85 ("google-maps", "modelcontextprotocol/servers"),
86 ("slack", "modelcontextprotocol/servers"),
87 ("sqlite", "modelcontextprotocol/servers"),
88 ("postgres", "modelcontextprotocol/servers"),
89 ("time", "modelcontextprotocol/servers"),
90 ];
91 let entries = BUILTIN
92 .iter()
93 .map(|(name, source)| {
94 (
95 (*name).to_string(),
96 RegistryEntry {
97 name: (*name).to_string(),
98 source: (*source).to_string(),
99 },
100 )
101 })
102 .collect();
103 Self { entries }
104 }
105
106 pub fn len(&self) -> usize {
109 self.entries.len()
110 }
111
112 pub fn is_empty(&self) -> bool {
114 self.entries.is_empty()
115 }
116
117 pub fn get(&self, name: &str) -> Option<&RegistryEntry> {
119 self.entries.get(name)
120 }
121
122 pub fn closest(&self, name: &str, max: usize) -> Option<(&RegistryEntry, usize)> {
126 let mut best: Option<(&RegistryEntry, usize)> = None;
127 for entry in self.entries.values() {
128 let d = levenshtein(name, &entry.name);
129 if d == 0 || d > max {
130 continue;
131 }
132 best = match best {
133 Some((_, bd)) if bd <= d => best,
134 _ => Some((entry, d)),
135 };
136 }
137 best
138 }
139
140 pub async fn fetch(url: &str) -> Result<Self> {
148 let client = reqwest::Client::builder()
149 .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
150 .user_agent(USER_AGENT)
151 .build()?;
152 let resp = client.get(url).send().await?;
153 if !resp.status().is_success() {
154 return Err(Error::Sanitize(format!(
155 "registry fetch {url} returned HTTP {}",
156 resp.status()
157 )));
158 }
159 let entries: Vec<RegistryEntry> = resp.json().await?;
160 Ok(Self::from_entries(entries))
161 }
162
163 pub fn from_entries(entries: Vec<RegistryEntry>) -> Self {
166 let entries = entries.into_iter().map(|e| (e.name.clone(), e)).collect();
167 Self { entries }
168 }
169
170 pub fn cache_path(paths: &Paths) -> PathBuf {
172 paths.home.join("registry.json")
173 }
174
175 pub fn load_cached(paths: &Paths) -> Result<Option<Self>> {
183 let path = Self::cache_path(paths);
184 if !path.exists() {
185 return Ok(None);
186 }
187 let body = fs::read_to_string(&path)?;
188 let reg: Self = serde_json::from_str(&body)?;
189 Ok(Some(reg))
190 }
191
192 pub fn save_cached(&self, paths: &Paths) -> Result<PathBuf> {
199 fs::create_dir_all(&paths.home)?;
200 let path = Self::cache_path(paths);
201 let body = serde_json::to_string_pretty(self)?;
202 fs::write(&path, body)?;
203 Ok(path)
204 }
205
206 pub fn load_or_builtin(paths: &Paths) -> Self {
211 Self::load_cached(paths)
212 .ok()
213 .flatten()
214 .unwrap_or_else(Self::builtin)
215 }
216}
217
218fn levenshtein(a: &str, b: &str) -> usize {
222 let a = a.to_lowercase();
223 let b = b.to_lowercase();
224 let av: Vec<u8> = a.bytes().collect();
225 let bv: Vec<u8> = b.bytes().collect();
226 let (n, m) = (av.len(), bv.len());
227 if n == 0 {
228 return m;
229 }
230 if m == 0 {
231 return n;
232 }
233 let mut prev: Vec<usize> = (0..=m).collect();
234 let mut curr: Vec<usize> = vec![0; m + 1];
235 for i in 1..=n {
236 curr[0] = i;
237 for j in 1..=m {
238 let cost = usize::from(av[i - 1] != bv[j - 1]);
239 curr[j] = (curr[j - 1] + 1) .min(prev[j] + 1) .min(prev[j - 1] + cost); }
243 std::mem::swap(&mut prev, &mut curr);
244 }
245 prev[m]
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 fn paths_for(tmp: &tempfile::TempDir) -> Paths {
253 Paths {
254 home: tmp.path().to_path_buf(),
255 user_home: tmp.path().to_path_buf(),
256 }
257 }
258
259 #[test]
260 fn builtin_is_non_empty_and_contains_filesystem() {
261 let r = Registry::builtin();
262 assert!(!r.is_empty());
263 assert!(r.get("filesystem").is_some());
264 }
265
266 #[test]
267 fn exact_lookup_is_case_sensitive() {
268 let r = Registry::builtin();
272 assert!(r.get("filesystem").is_some());
273 assert!(r.get("FileSystem").is_none());
274 }
275
276 #[test]
277 fn closest_finds_typosquats_within_distance() {
278 let r = Registry::builtin();
279 let (entry, d) = r.closest("filesystme", 2).expect("typosquat hit");
282 assert_eq!(entry.name, "filesystem");
283 assert!(d > 0 && d <= 2);
284 }
285
286 #[test]
287 fn closest_skips_exact_matches() {
288 let r = Registry::builtin();
289 assert!(r.closest("filesystem", 2).is_none());
290 }
291
292 #[test]
293 fn closest_returns_none_when_outside_distance() {
294 let r = Registry::builtin();
295 assert!(r.closest("completely-unrelated-name", 2).is_none());
296 }
297
298 #[test]
299 fn cache_round_trip() {
300 let tmp = tempfile::tempdir().unwrap();
301 let paths = paths_for(&tmp);
302 let original = Registry::builtin();
303 let written = original.save_cached(&paths).unwrap();
304 assert!(written.exists());
305 let loaded = Registry::load_cached(&paths).unwrap().unwrap();
306 assert_eq!(loaded.len(), original.len());
307 }
308
309 #[test]
310 fn load_or_builtin_uses_cache_when_present() {
311 let tmp = tempfile::tempdir().unwrap();
312 let paths = paths_for(&tmp);
313 let custom = Registry::from_entries(vec![RegistryEntry {
316 name: "my-internal-server".into(),
317 source: "private allowlist".into(),
318 }]);
319 custom.save_cached(&paths).unwrap();
320 let resolved = Registry::load_or_builtin(&paths);
321 assert_eq!(resolved.len(), 1);
322 assert!(resolved.get("my-internal-server").is_some());
323 assert!(resolved.get("filesystem").is_none());
324 }
325
326 #[test]
327 fn load_or_builtin_falls_back_when_no_cache() {
328 let tmp = tempfile::tempdir().unwrap();
329 let paths = paths_for(&tmp);
330 let resolved = Registry::load_or_builtin(&paths);
331 assert!(resolved.get("filesystem").is_some());
332 }
333
334 #[test]
335 fn levenshtein_known_pairs() {
336 assert_eq!(levenshtein("kitten", "sitting"), 3);
337 assert_eq!(levenshtein("a", "a"), 0);
338 assert_eq!(levenshtein("", "abc"), 3);
339 assert_eq!(levenshtein("abc", ""), 3);
340 assert_eq!(levenshtein("GitHub", "github"), 0); }
342}