use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use crate::Paths;
use crate::error::{Error, Result};
pub const DEFAULT_SOURCE_URL: &str =
"https://raw.githubusercontent.com/modelcontextprotocol/servers/main/.well-known/registry.json";
const FETCH_TIMEOUT_SECS: u64 = 10;
const USER_AGENT: &str = concat!("agentsec-registry/", env!("CARGO_PKG_VERSION"));
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryEntry {
pub name: String,
pub source: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Registry {
pub entries: HashMap<String, RegistryEntry>,
}
impl Registry {
pub fn builtin() -> Self {
const BUILTIN: &[(&str, &str)] = &[
("filesystem", "modelcontextprotocol/servers"),
("github", "modelcontextprotocol/servers"),
("gitlab", "modelcontextprotocol/servers"),
("git", "modelcontextprotocol/servers"),
("memory", "modelcontextprotocol/servers"),
("fetch", "modelcontextprotocol/servers"),
("puppeteer", "modelcontextprotocol/servers"),
("sequential-thinking", "modelcontextprotocol/servers"),
("everything", "modelcontextprotocol/servers"),
("brave-search", "modelcontextprotocol/servers"),
("google-maps", "modelcontextprotocol/servers"),
("slack", "modelcontextprotocol/servers"),
("sqlite", "modelcontextprotocol/servers"),
("postgres", "modelcontextprotocol/servers"),
("time", "modelcontextprotocol/servers"),
];
let entries = BUILTIN
.iter()
.map(|(name, source)| {
(
(*name).to_string(),
RegistryEntry {
name: (*name).to_string(),
source: (*source).to_string(),
},
)
})
.collect();
Self { entries }
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn get(&self, name: &str) -> Option<&RegistryEntry> {
self.entries.get(name)
}
pub fn closest(&self, name: &str, max: usize) -> Option<(&RegistryEntry, usize)> {
let mut best: Option<(&RegistryEntry, usize)> = None;
for entry in self.entries.values() {
let d = levenshtein(name, &entry.name);
if d == 0 || d > max {
continue;
}
best = match best {
Some((_, bd)) if bd <= d => best,
_ => Some((entry, d)),
};
}
best
}
pub async fn fetch(url: &str) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(FETCH_TIMEOUT_SECS))
.user_agent(USER_AGENT)
.build()?;
let resp = client.get(url).send().await?;
if !resp.status().is_success() {
return Err(Error::Sanitize(format!(
"registry fetch {url} returned HTTP {}",
resp.status()
)));
}
let entries: Vec<RegistryEntry> = resp.json().await?;
Ok(Self::from_entries(entries))
}
pub fn from_entries(entries: Vec<RegistryEntry>) -> Self {
let entries = entries.into_iter().map(|e| (e.name.clone(), e)).collect();
Self { entries }
}
pub fn cache_path(paths: &Paths) -> PathBuf {
paths.home.join("registry.json")
}
pub fn load_cached(paths: &Paths) -> Result<Option<Self>> {
let path = Self::cache_path(paths);
if !path.exists() {
return Ok(None);
}
let body = fs::read_to_string(&path)?;
let reg: Self = serde_json::from_str(&body)?;
Ok(Some(reg))
}
pub fn save_cached(&self, paths: &Paths) -> Result<PathBuf> {
fs::create_dir_all(&paths.home)?;
let path = Self::cache_path(paths);
let body = serde_json::to_string_pretty(self)?;
fs::write(&path, body)?;
Ok(path)
}
pub fn load_or_builtin(paths: &Paths) -> Self {
Self::load_cached(paths)
.ok()
.flatten()
.unwrap_or_else(Self::builtin)
}
pub fn local_path(paths: &Paths) -> PathBuf {
paths.home.join("registry-local.json")
}
pub fn load_local(paths: &Paths) -> Result<Self> {
let path = Self::local_path(paths);
if !path.exists() {
return Ok(Self::default());
}
let body = fs::read_to_string(&path)?;
let reg: Self = serde_json::from_str(&body)?;
Ok(reg)
}
pub fn save_local(&self, paths: &Paths) -> Result<()> {
fs::create_dir_all(&paths.home)?;
let path = Self::local_path(paths);
let body = serde_json::to_string_pretty(self)?;
fs::write(&path, body)?;
Ok(())
}
pub fn add_local(paths: &Paths, name: &str, source: Option<&str>) -> Result<()> {
let mut reg = Self::load_local(paths)?;
let entry = RegistryEntry {
name: name.to_string(),
source: source.unwrap_or("local").to_string(),
};
reg.entries.insert(name.to_string(), entry);
reg.save_local(paths)
}
pub fn remove_local(paths: &Paths, name: &str) -> Result<()> {
let mut reg = Self::load_local(paths)?;
reg.entries.remove(name);
reg.save_local(paths)
}
pub fn load_resolved(paths: &Paths) -> Result<Self> {
let mut merged = Self::builtin();
if let Some(fetched) = Self::load_cached(paths)? {
for (name, entry) in fetched.entries {
merged.entries.insert(name, entry);
}
}
let local = Self::load_local(paths)?;
for (name, entry) in local.entries {
merged.entries.insert(name, entry);
}
Ok(merged)
}
}
fn levenshtein(a: &str, b: &str) -> usize {
let a = a.to_lowercase();
let b = b.to_lowercase();
let av: Vec<u8> = a.bytes().collect();
let bv: Vec<u8> = b.bytes().collect();
let (n, m) = (av.len(), bv.len());
if n == 0 {
return m;
}
if m == 0 {
return n;
}
let mut prev: Vec<usize> = (0..=m).collect();
let mut curr: Vec<usize> = vec![0; m + 1];
for i in 1..=n {
curr[0] = i;
for j in 1..=m {
let cost = usize::from(av[i - 1] != bv[j - 1]);
curr[j] = (curr[j - 1] + 1) .min(prev[j] + 1) .min(prev[j - 1] + cost); }
std::mem::swap(&mut prev, &mut curr);
}
prev[m]
}
#[cfg(test)]
mod tests {
use super::*;
fn paths_for(tmp: &tempfile::TempDir) -> Paths {
Paths {
home: tmp.path().to_path_buf(),
user_home: tmp.path().to_path_buf(),
}
}
#[test]
fn builtin_is_non_empty_and_contains_filesystem() {
let r = Registry::builtin();
assert!(!r.is_empty());
assert!(r.get("filesystem").is_some());
}
#[test]
fn exact_lookup_is_case_sensitive() {
let r = Registry::builtin();
assert!(r.get("filesystem").is_some());
assert!(r.get("FileSystem").is_none());
}
#[test]
fn closest_finds_typosquats_within_distance() {
let r = Registry::builtin();
let (entry, d) = r.closest("filesystme", 2).expect("typosquat hit");
assert_eq!(entry.name, "filesystem");
assert!(d > 0 && d <= 2);
}
#[test]
fn closest_skips_exact_matches() {
let r = Registry::builtin();
assert!(r.closest("filesystem", 2).is_none());
}
#[test]
fn closest_returns_none_when_outside_distance() {
let r = Registry::builtin();
assert!(r.closest("completely-unrelated-name", 2).is_none());
}
#[test]
fn cache_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
let original = Registry::builtin();
let written = original.save_cached(&paths).unwrap();
assert!(written.exists());
let loaded = Registry::load_cached(&paths).unwrap().unwrap();
assert_eq!(loaded.len(), original.len());
}
#[test]
fn load_or_builtin_uses_cache_when_present() {
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
let custom = Registry::from_entries(vec![RegistryEntry {
name: "my-internal-server".into(),
source: "private allowlist".into(),
}]);
custom.save_cached(&paths).unwrap();
let resolved = Registry::load_or_builtin(&paths);
assert_eq!(resolved.len(), 1);
assert!(resolved.get("my-internal-server").is_some());
assert!(resolved.get("filesystem").is_none());
}
#[test]
fn load_or_builtin_falls_back_when_no_cache() {
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
let resolved = Registry::load_or_builtin(&paths);
assert!(resolved.get("filesystem").is_some());
}
#[test]
fn levenshtein_known_pairs() {
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("a", "a"), 0);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("GitHub", "github"), 0); }
#[test]
fn load_resolved_empty_local_falls_back_to_builtin_only() {
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
let resolved = Registry::load_resolved(&paths).unwrap();
let builtin = Registry::builtin();
assert_eq!(resolved.len(), builtin.len());
assert!(resolved.get("filesystem").is_some());
}
#[test]
fn load_resolved_local_overrides_builtin() {
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
Registry::add_local(&paths, "filesystem", Some("my-fork")).unwrap();
let resolved = Registry::load_resolved(&paths).unwrap();
let entry = resolved.get("filesystem").unwrap();
assert_eq!(entry.source, "my-fork");
}
#[test]
fn load_resolved_local_overrides_fetched() {
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
let fetched = Registry::from_entries(vec![RegistryEntry {
name: "github".into(),
source: "fetched-registry".into(),
}]);
fetched.save_cached(&paths).unwrap();
Registry::add_local(&paths, "github", Some("personal-fork")).unwrap();
let resolved = Registry::load_resolved(&paths).unwrap();
let entry = resolved.get("github").unwrap();
assert_eq!(entry.source, "personal-fork");
}
#[test]
fn add_local_persists_across_reload() {
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
Registry::add_local(&paths, "my-custom-server", None).unwrap();
let loaded = Registry::load_local(&paths).unwrap();
let entry = loaded.get("my-custom-server").unwrap();
assert_eq!(entry.source, "local");
}
#[test]
fn remove_local_removes_entry() {
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
Registry::add_local(&paths, "to-remove", Some("test")).unwrap();
assert!(
Registry::load_local(&paths)
.unwrap()
.get("to-remove")
.is_some()
);
Registry::remove_local(&paths, "to-remove").unwrap();
assert!(
Registry::load_local(&paths)
.unwrap()
.get("to-remove")
.is_none()
);
}
}