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)
}
}
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); }
}