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 pub fn local_path(paths: &Paths) -> PathBuf {
221 paths.home.join("registry-local.json")
222 }
223
224 pub fn load_local(paths: &Paths) -> Result<Self> {
232 let path = Self::local_path(paths);
233 if !path.exists() {
234 return Ok(Self::default());
235 }
236 let body = fs::read_to_string(&path)?;
237 let reg: Self = serde_json::from_str(&body)?;
238 Ok(reg)
239 }
240
241 pub fn save_local(&self, paths: &Paths) -> Result<()> {
248 fs::create_dir_all(&paths.home)?;
249 let path = Self::local_path(paths);
250 let body = serde_json::to_string_pretty(self)?;
251 fs::write(&path, body)?;
252 Ok(())
253 }
254
255 pub fn add_local(paths: &Paths, name: &str, source: Option<&str>) -> Result<()> {
264 let mut reg = Self::load_local(paths)?;
265 let entry = RegistryEntry {
266 name: name.to_string(),
267 source: source.unwrap_or("local").to_string(),
268 };
269 reg.entries.insert(name.to_string(), entry);
270 reg.save_local(paths)
271 }
272
273 pub fn remove_local(paths: &Paths, name: &str) -> Result<()> {
281 let mut reg = Self::load_local(paths)?;
282 reg.entries.remove(name);
283 reg.save_local(paths)
284 }
285
286 pub fn load_resolved(paths: &Paths) -> Result<Self> {
296 let mut merged = Self::builtin();
298 if let Some(fetched) = Self::load_cached(paths)? {
300 for (name, entry) in fetched.entries {
301 merged.entries.insert(name, entry);
302 }
303 }
304 let local = Self::load_local(paths)?;
306 for (name, entry) in local.entries {
307 merged.entries.insert(name, entry);
308 }
309 Ok(merged)
310 }
311}
312
313fn levenshtein(a: &str, b: &str) -> usize {
317 let a = a.to_lowercase();
318 let b = b.to_lowercase();
319 let av: Vec<u8> = a.bytes().collect();
320 let bv: Vec<u8> = b.bytes().collect();
321 let (n, m) = (av.len(), bv.len());
322 if n == 0 {
323 return m;
324 }
325 if m == 0 {
326 return n;
327 }
328 let mut prev: Vec<usize> = (0..=m).collect();
329 let mut curr: Vec<usize> = vec![0; m + 1];
330 for i in 1..=n {
331 curr[0] = i;
332 for j in 1..=m {
333 let cost = usize::from(av[i - 1] != bv[j - 1]);
334 curr[j] = (curr[j - 1] + 1) .min(prev[j] + 1) .min(prev[j - 1] + cost); }
338 std::mem::swap(&mut prev, &mut curr);
339 }
340 prev[m]
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 fn paths_for(tmp: &tempfile::TempDir) -> Paths {
348 Paths {
349 home: tmp.path().to_path_buf(),
350 user_home: tmp.path().to_path_buf(),
351 }
352 }
353
354 #[test]
355 fn builtin_is_non_empty_and_contains_filesystem() {
356 let r = Registry::builtin();
357 assert!(!r.is_empty());
358 assert!(r.get("filesystem").is_some());
359 }
360
361 #[test]
362 fn exact_lookup_is_case_sensitive() {
363 let r = Registry::builtin();
367 assert!(r.get("filesystem").is_some());
368 assert!(r.get("FileSystem").is_none());
369 }
370
371 #[test]
372 fn closest_finds_typosquats_within_distance() {
373 let r = Registry::builtin();
374 let (entry, d) = r.closest("filesystme", 2).expect("typosquat hit");
377 assert_eq!(entry.name, "filesystem");
378 assert!(d > 0 && d <= 2);
379 }
380
381 #[test]
382 fn closest_skips_exact_matches() {
383 let r = Registry::builtin();
384 assert!(r.closest("filesystem", 2).is_none());
385 }
386
387 #[test]
388 fn closest_returns_none_when_outside_distance() {
389 let r = Registry::builtin();
390 assert!(r.closest("completely-unrelated-name", 2).is_none());
391 }
392
393 #[test]
394 fn cache_round_trip() {
395 let tmp = tempfile::tempdir().unwrap();
396 let paths = paths_for(&tmp);
397 let original = Registry::builtin();
398 let written = original.save_cached(&paths).unwrap();
399 assert!(written.exists());
400 let loaded = Registry::load_cached(&paths).unwrap().unwrap();
401 assert_eq!(loaded.len(), original.len());
402 }
403
404 #[test]
405 fn load_or_builtin_uses_cache_when_present() {
406 let tmp = tempfile::tempdir().unwrap();
407 let paths = paths_for(&tmp);
408 let custom = Registry::from_entries(vec![RegistryEntry {
411 name: "my-internal-server".into(),
412 source: "private allowlist".into(),
413 }]);
414 custom.save_cached(&paths).unwrap();
415 let resolved = Registry::load_or_builtin(&paths);
416 assert_eq!(resolved.len(), 1);
417 assert!(resolved.get("my-internal-server").is_some());
418 assert!(resolved.get("filesystem").is_none());
419 }
420
421 #[test]
422 fn load_or_builtin_falls_back_when_no_cache() {
423 let tmp = tempfile::tempdir().unwrap();
424 let paths = paths_for(&tmp);
425 let resolved = Registry::load_or_builtin(&paths);
426 assert!(resolved.get("filesystem").is_some());
427 }
428
429 #[test]
430 fn levenshtein_known_pairs() {
431 assert_eq!(levenshtein("kitten", "sitting"), 3);
432 assert_eq!(levenshtein("a", "a"), 0);
433 assert_eq!(levenshtein("", "abc"), 3);
434 assert_eq!(levenshtein("abc", ""), 3);
435 assert_eq!(levenshtein("GitHub", "github"), 0); }
437
438 #[test]
441 fn load_resolved_empty_local_falls_back_to_builtin_only() {
442 let tmp = tempfile::tempdir().unwrap();
443 let paths = paths_for(&tmp);
444 let resolved = Registry::load_resolved(&paths).unwrap();
446 let builtin = Registry::builtin();
447 assert_eq!(resolved.len(), builtin.len());
448 assert!(resolved.get("filesystem").is_some());
449 }
450
451 #[test]
452 fn load_resolved_local_overrides_builtin() {
453 let tmp = tempfile::tempdir().unwrap();
454 let paths = paths_for(&tmp);
455 Registry::add_local(&paths, "filesystem", Some("my-fork")).unwrap();
457 let resolved = Registry::load_resolved(&paths).unwrap();
458 let entry = resolved.get("filesystem").unwrap();
459 assert_eq!(entry.source, "my-fork");
460 }
461
462 #[test]
463 fn load_resolved_local_overrides_fetched() {
464 let tmp = tempfile::tempdir().unwrap();
465 let paths = paths_for(&tmp);
466 let fetched = Registry::from_entries(vec![RegistryEntry {
468 name: "github".into(),
469 source: "fetched-registry".into(),
470 }]);
471 fetched.save_cached(&paths).unwrap();
472 Registry::add_local(&paths, "github", Some("personal-fork")).unwrap();
474 let resolved = Registry::load_resolved(&paths).unwrap();
475 let entry = resolved.get("github").unwrap();
476 assert_eq!(entry.source, "personal-fork");
477 }
478
479 #[test]
480 fn add_local_persists_across_reload() {
481 let tmp = tempfile::tempdir().unwrap();
482 let paths = paths_for(&tmp);
483 Registry::add_local(&paths, "my-custom-server", None).unwrap();
484 let loaded = Registry::load_local(&paths).unwrap();
485 let entry = loaded.get("my-custom-server").unwrap();
486 assert_eq!(entry.source, "local");
487 }
488
489 #[test]
490 fn remove_local_removes_entry() {
491 let tmp = tempfile::tempdir().unwrap();
492 let paths = paths_for(&tmp);
493 Registry::add_local(&paths, "to-remove", Some("test")).unwrap();
494 assert!(
495 Registry::load_local(&paths)
496 .unwrap()
497 .get("to-remove")
498 .is_some()
499 );
500 Registry::remove_local(&paths, "to-remove").unwrap();
501 assert!(
502 Registry::load_local(&paths)
503 .unwrap()
504 .get("to-remove")
505 .is_none()
506 );
507 }
508}