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,
57 pub source: String,
61}
62
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
66pub struct Registry {
67 pub entries: HashMap<String, RegistryEntry>,
69}
70
71impl Registry {
72 pub fn builtin() -> Self {
75 const BUILTIN: &[(&str, &str)] = &[
76 ("filesystem", "modelcontextprotocol/servers"),
77 ("github", "modelcontextprotocol/servers"),
78 ("gitlab", "modelcontextprotocol/servers"),
79 ("git", "modelcontextprotocol/servers"),
80 ("memory", "modelcontextprotocol/servers"),
81 ("fetch", "modelcontextprotocol/servers"),
82 ("puppeteer", "modelcontextprotocol/servers"),
83 ("sequential-thinking", "modelcontextprotocol/servers"),
84 ("everything", "modelcontextprotocol/servers"),
85 ("brave-search", "modelcontextprotocol/servers"),
86 ("google-maps", "modelcontextprotocol/servers"),
87 ("slack", "modelcontextprotocol/servers"),
88 ("sqlite", "modelcontextprotocol/servers"),
89 ("postgres", "modelcontextprotocol/servers"),
90 ("time", "modelcontextprotocol/servers"),
91 ];
92 let entries = BUILTIN
93 .iter()
94 .map(|(name, source)| {
95 (
96 (*name).to_string(),
97 RegistryEntry {
98 name: (*name).to_string(),
99 source: (*source).to_string(),
100 },
101 )
102 })
103 .collect();
104 Self { entries }
105 }
106
107 pub fn len(&self) -> usize {
110 self.entries.len()
111 }
112
113 pub fn is_empty(&self) -> bool {
115 self.entries.is_empty()
116 }
117
118 pub fn get(&self, name: &str) -> Option<&RegistryEntry> {
120 self.entries.get(name)
121 }
122
123 pub fn closest(&self, name: &str, max: usize) -> Option<(&RegistryEntry, usize)> {
127 let mut best: Option<(&RegistryEntry, usize)> = None;
128 for entry in self.entries.values() {
129 let d = levenshtein(name, &entry.name);
130 if d == 0 || d > max {
131 continue;
132 }
133 best = match best {
134 Some((_, bd)) if bd <= d => best,
135 _ => Some((entry, d)),
136 };
137 }
138 best
139 }
140
141 pub async fn fetch(url: &str) -> Result<Self> {
149 let client = reqwest::Client::builder()
150 .timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
151 .user_agent(USER_AGENT)
152 .build()?;
153 let resp = client.get(url).send().await?;
154 if !resp.status().is_success() {
155 return Err(Error::Sanitize(format!(
156 "registry fetch {url} returned HTTP {}",
157 resp.status()
158 )));
159 }
160 let entries: Vec<RegistryEntry> = resp.json().await?;
161 Ok(Self::from_entries(entries))
162 }
163
164 pub fn from_entries(entries: Vec<RegistryEntry>) -> Self {
167 let entries = entries.into_iter().map(|e| (e.name.clone(), e)).collect();
168 Self { entries }
169 }
170
171 pub fn cache_path(paths: &Paths) -> PathBuf {
173 paths.home.join("registry.json")
174 }
175
176 pub fn load_cached(paths: &Paths) -> Result<Option<Self>> {
184 let path = Self::cache_path(paths);
185 if !path.exists() {
186 return Ok(None);
187 }
188 let body = fs::read_to_string(&path)?;
189 let reg: Self = serde_json::from_str(&body)?;
190 Ok(Some(reg))
191 }
192
193 pub fn save_cached(&self, paths: &Paths) -> Result<PathBuf> {
200 fs::create_dir_all(&paths.home)?;
201 let path = Self::cache_path(paths);
202 let body = serde_json::to_string_pretty(self)?;
203 fs::write(&path, body)?;
204 Ok(path)
205 }
206
207 pub fn load_or_builtin(paths: &Paths) -> Self {
212 Self::load_cached(paths)
213 .ok()
214 .flatten()
215 .unwrap_or_else(Self::builtin)
216 }
217
218 pub fn local_path(paths: &Paths) -> PathBuf {
222 paths.home.join("registry-local.json")
223 }
224
225 pub fn load_local(paths: &Paths) -> Result<Self> {
233 let path = Self::local_path(paths);
234 if !path.exists() {
235 return Ok(Self::default());
236 }
237 let body = fs::read_to_string(&path)?;
238 let reg: Self = serde_json::from_str(&body)?;
239 Ok(reg)
240 }
241
242 pub fn save_local(&self, paths: &Paths) -> Result<()> {
249 fs::create_dir_all(&paths.home)?;
250 let path = Self::local_path(paths);
251 let body = serde_json::to_string_pretty(self)?;
252 fs::write(&path, body)?;
253 Ok(())
254 }
255
256 pub fn add_local(paths: &Paths, name: &str, source: Option<&str>) -> Result<()> {
265 let mut reg = Self::load_local(paths)?;
266 let entry = RegistryEntry {
267 name: name.to_string(),
268 source: source.unwrap_or("local").to_string(),
269 };
270 reg.entries.insert(name.to_string(), entry);
271 reg.save_local(paths)
272 }
273
274 pub fn remove_local(paths: &Paths, name: &str) -> Result<()> {
282 let mut reg = Self::load_local(paths)?;
283 reg.entries.remove(name);
284 reg.save_local(paths)
285 }
286
287 pub fn load_resolved(paths: &Paths) -> Result<Self> {
297 let mut merged = Self::builtin();
299 if let Some(fetched) = Self::load_cached(paths)? {
301 for (name, entry) in fetched.entries {
302 merged.entries.insert(name, entry);
303 }
304 }
305 let local = Self::load_local(paths)?;
307 for (name, entry) in local.entries {
308 merged.entries.insert(name, entry);
309 }
310 Ok(merged)
311 }
312}
313
314fn levenshtein(a: &str, b: &str) -> usize {
318 let a = a.to_lowercase();
319 let b = b.to_lowercase();
320 let av: Vec<u8> = a.bytes().collect();
321 let bv: Vec<u8> = b.bytes().collect();
322 let (n, m) = (av.len(), bv.len());
323 if n == 0 {
324 return m;
325 }
326 if m == 0 {
327 return n;
328 }
329 let mut prev: Vec<usize> = (0..=m).collect();
330 let mut curr: Vec<usize> = vec![0; m + 1];
331 for i in 1..=n {
332 curr[0] = i;
333 for j in 1..=m {
334 let cost = usize::from(av[i - 1] != bv[j - 1]);
335 curr[j] = (curr[j - 1] + 1) .min(prev[j] + 1) .min(prev[j - 1] + cost); }
339 std::mem::swap(&mut prev, &mut curr);
340 }
341 prev[m]
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347
348 fn paths_for(tmp: &tempfile::TempDir) -> Paths {
349 Paths {
350 home: tmp.path().to_path_buf(),
351 user_home: tmp.path().to_path_buf(),
352 }
353 }
354
355 #[test]
356 fn builtin_is_non_empty_and_contains_filesystem() {
357 let r = Registry::builtin();
358 assert!(!r.is_empty());
359 assert!(r.get("filesystem").is_some());
360 }
361
362 #[test]
363 fn exact_lookup_is_case_sensitive() {
364 let r = Registry::builtin();
368 assert!(r.get("filesystem").is_some());
369 assert!(r.get("FileSystem").is_none());
370 }
371
372 #[test]
373 fn closest_finds_typosquats_within_distance() {
374 let r = Registry::builtin();
375 let (entry, d) = r.closest("filesystme", 2).expect("typosquat hit");
378 assert_eq!(entry.name, "filesystem");
379 assert!(d > 0 && d <= 2);
380 }
381
382 #[test]
383 fn closest_skips_exact_matches() {
384 let r = Registry::builtin();
385 assert!(r.closest("filesystem", 2).is_none());
386 }
387
388 #[test]
389 fn closest_returns_none_when_outside_distance() {
390 let r = Registry::builtin();
391 assert!(r.closest("completely-unrelated-name", 2).is_none());
392 }
393
394 #[test]
395 fn cache_round_trip() {
396 let tmp = tempfile::tempdir().unwrap();
397 let paths = paths_for(&tmp);
398 let original = Registry::builtin();
399 let written = original.save_cached(&paths).unwrap();
400 assert!(written.exists());
401 let loaded = Registry::load_cached(&paths).unwrap().unwrap();
402 assert_eq!(loaded.len(), original.len());
403 }
404
405 #[test]
406 fn load_or_builtin_uses_cache_when_present() {
407 let tmp = tempfile::tempdir().unwrap();
408 let paths = paths_for(&tmp);
409 let custom = Registry::from_entries(vec![RegistryEntry {
412 name: "my-internal-server".into(),
413 source: "private allowlist".into(),
414 }]);
415 custom.save_cached(&paths).unwrap();
416 let resolved = Registry::load_or_builtin(&paths);
417 assert_eq!(resolved.len(), 1);
418 assert!(resolved.get("my-internal-server").is_some());
419 assert!(resolved.get("filesystem").is_none());
420 }
421
422 #[test]
423 fn load_or_builtin_falls_back_when_no_cache() {
424 let tmp = tempfile::tempdir().unwrap();
425 let paths = paths_for(&tmp);
426 let resolved = Registry::load_or_builtin(&paths);
427 assert!(resolved.get("filesystem").is_some());
428 }
429
430 #[test]
431 fn levenshtein_known_pairs() {
432 assert_eq!(levenshtein("kitten", "sitting"), 3);
433 assert_eq!(levenshtein("a", "a"), 0);
434 assert_eq!(levenshtein("", "abc"), 3);
435 assert_eq!(levenshtein("abc", ""), 3);
436 assert_eq!(levenshtein("GitHub", "github"), 0); }
438
439 #[test]
442 fn load_resolved_empty_local_falls_back_to_builtin_only() {
443 let tmp = tempfile::tempdir().unwrap();
444 let paths = paths_for(&tmp);
445 let resolved = Registry::load_resolved(&paths).unwrap();
447 let builtin = Registry::builtin();
448 assert_eq!(resolved.len(), builtin.len());
449 assert!(resolved.get("filesystem").is_some());
450 }
451
452 #[test]
453 fn load_resolved_local_overrides_builtin() {
454 let tmp = tempfile::tempdir().unwrap();
455 let paths = paths_for(&tmp);
456 Registry::add_local(&paths, "filesystem", Some("my-fork")).unwrap();
458 let resolved = Registry::load_resolved(&paths).unwrap();
459 let entry = resolved.get("filesystem").unwrap();
460 assert_eq!(entry.source, "my-fork");
461 }
462
463 #[test]
464 fn load_resolved_local_overrides_fetched() {
465 let tmp = tempfile::tempdir().unwrap();
466 let paths = paths_for(&tmp);
467 let fetched = Registry::from_entries(vec![RegistryEntry {
469 name: "github".into(),
470 source: "fetched-registry".into(),
471 }]);
472 fetched.save_cached(&paths).unwrap();
473 Registry::add_local(&paths, "github", Some("personal-fork")).unwrap();
475 let resolved = Registry::load_resolved(&paths).unwrap();
476 let entry = resolved.get("github").unwrap();
477 assert_eq!(entry.source, "personal-fork");
478 }
479
480 #[test]
481 fn add_local_persists_across_reload() {
482 let tmp = tempfile::tempdir().unwrap();
483 let paths = paths_for(&tmp);
484 Registry::add_local(&paths, "my-custom-server", None).unwrap();
485 let loaded = Registry::load_local(&paths).unwrap();
486 let entry = loaded.get("my-custom-server").unwrap();
487 assert_eq!(entry.source, "local");
488 }
489
490 #[test]
491 fn remove_local_removes_entry() {
492 let tmp = tempfile::tempdir().unwrap();
493 let paths = paths_for(&tmp);
494 Registry::add_local(&paths, "to-remove", Some("test")).unwrap();
495 assert!(
496 Registry::load_local(&paths)
497 .unwrap()
498 .get("to-remove")
499 .is_some()
500 );
501 Registry::remove_local(&paths, "to-remove").unwrap();
502 assert!(
503 Registry::load_local(&paths)
504 .unwrap()
505 .get("to-remove")
506 .is_none()
507 );
508 }
509}