use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::Paths;
use crate::registry::Registry;
const TYPOSQUAT_DISTANCE_MAX: usize = 2;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UnknownVerdict {
pub name: String,
pub verdict: UnknownVerdictKind,
pub reason: String,
pub count: usize,
pub paths: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum UnknownVerdictKind {
KnownGood,
LikelyTyposquat { closest: String, distance: usize },
InformationalTyposquat { closest: String, distance: usize },
Unknown,
}
pub fn classify(paths: &Paths, registry: &Registry) -> Vec<UnknownVerdict> {
let mut named: BTreeSet<(String, String)> = BTreeSet::new();
let project_mcp = PathBuf::from(".mcp.json");
extract_names(&project_mcp, &mut named, false);
let home_config = paths.user_home.join(".claude.json");
extract_names(&home_config, &mut named, true);
let mut by_name: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (path, name) in named {
by_name.entry(name).or_default().insert(path);
}
by_name
.into_iter()
.map(|(name, path_set)| {
let paths_vec: Vec<String> = path_set.into_iter().collect();
let count = paths_vec.len();
let rep = paths_vec.first().map_or("", String::as_str);
let mut v = classify_one(rep, &name, registry);
v.count = count;
v.paths = paths_vec;
v
})
.collect()
}
fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
if let Some(entry) = registry.get(name) {
return UnknownVerdict {
name: name.to_string(),
verdict: UnknownVerdictKind::KnownGood,
reason: format!("registered (source: {})", entry.source),
count: 1,
paths: vec![path.to_string()],
};
}
if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
let char_count = name.chars().count();
if distance == 2 && char_count <= 3 {
} else {
let verdict = if distance == 1 {
UnknownVerdictKind::LikelyTyposquat {
closest: entry.name.clone(),
distance,
}
} else {
UnknownVerdictKind::InformationalTyposquat {
closest: entry.name.clone(),
distance,
}
};
return UnknownVerdict {
name: name.to_string(),
verdict,
reason: format!("close to `{}` (distance {})", entry.name, distance),
count: 1,
paths: vec![path.to_string()],
};
}
}
UnknownVerdict {
name: name.to_string(),
verdict: UnknownVerdictKind::Unknown,
reason: "not in registry".to_string(),
count: 1,
paths: vec![path.to_string()],
}
}
fn extract_names(path: &PathBuf, out: &mut BTreeSet<(String, String)>, per_project: bool) {
let Ok(body) = fs::read_to_string(path) else {
return;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) else {
return;
};
let display = path.display().to_string();
if let Some(top) = json
.get("mcpServers")
.and_then(serde_json::Value::as_object)
{
for name in top.keys() {
out.insert((display.clone(), name.clone()));
}
}
if per_project {
if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
for (proj_name, proj_val) in projects {
if let Some(servers) = proj_val
.get("mcpServers")
.and_then(serde_json::Value::as_object)
{
for name in servers.keys() {
let scoped = format!("{display}#projects.{proj_name}.mcpServers");
out.insert((scoped, name.clone()));
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::RegistryEntry;
fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
}
fn paths_for(tmp: &tempfile::TempDir) -> Paths {
Paths {
home: tmp.path().to_path_buf(),
user_home: tmp.path().to_path_buf(),
}
}
fn small_registry() -> Registry {
Registry::from_entries(vec![
RegistryEntry {
name: "filesystem".into(),
source: "test-source".into(),
},
RegistryEntry {
name: "github".into(),
source: "test-source".into(),
},
])
}
#[test]
fn known_good_is_recognised() {
let tmp = tempfile::tempdir().unwrap();
write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
let verdicts = classify(&paths_for(&tmp), &small_registry());
assert_eq!(verdicts.len(), 1);
assert_eq!(verdicts[0].name, "filesystem");
assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
}
#[test]
fn typosquat_is_flagged() {
let tmp = tempfile::tempdir().unwrap();
write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
let verdicts = classify(&paths_for(&tmp), &small_registry());
assert_eq!(verdicts.len(), 1);
match &verdicts[0].verdict {
UnknownVerdictKind::InformationalTyposquat { closest, distance } => {
assert_eq!(closest, "filesystem");
assert_eq!(*distance, 2);
}
other => panic!("expected InformationalTyposquat, got {other:?}"),
}
}
#[test]
fn unknown_name_is_unknown() {
let tmp = tempfile::tempdir().unwrap();
write_claude_json(
&tmp,
r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
);
let verdicts = classify(&paths_for(&tmp), &small_registry());
assert_eq!(verdicts.len(), 1);
assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
}
#[test]
fn per_project_servers_are_classified() {
let tmp = tempfile::tempdir().unwrap();
write_claude_json(
&tmp,
r#"{
"mcpServers": {"github": {"command":"x"}},
"projects": {
"/some/proj": {
"mcpServers": {"my-tool": {"command":"y"}}
}
}
}"#,
);
let verdicts = classify(&paths_for(&tmp), &small_registry());
assert_eq!(verdicts.len(), 2);
let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
assert!(names.contains(&"github"));
assert!(names.contains(&"my-tool"));
}
#[test]
fn missing_claude_json_yields_empty_result() {
let tmp = tempfile::tempdir().unwrap();
let verdicts = classify(&paths_for(&tmp), &small_registry());
assert!(verdicts.is_empty());
}
#[test]
fn malformed_json_silently_skipped() {
let tmp = tempfile::tempdir().unwrap();
write_claude_json(&tmp, "not even close to JSON");
let verdicts = classify(&paths_for(&tmp), &small_registry());
assert!(verdicts.is_empty());
}
#[test]
fn classify_one_short_name_distance_2_suppressed() {
let reg = Registry::from_entries(vec![RegistryEntry {
name: "liv".into(),
source: "test".into(),
}]);
let reg2 = Registry::from_entries(vec![RegistryEntry {
name: "git".into(),
source: "test".into(),
}]);
let reg3 = Registry::from_entries(vec![RegistryEntry {
name: "ab".into(),
source: "test".into(),
}]);
let v = classify_one("path", "cd", ®3);
assert_eq!(v.verdict, UnknownVerdictKind::Unknown);
let reg4 = Registry::from_entries(vec![RegistryEntry {
name: "abc".into(),
source: "test".into(),
}]);
let v2 = classify_one("path", "axz", ®4);
assert_eq!(v2.verdict, UnknownVerdictKind::Unknown);
let _ = reg;
let _ = reg2;
}
#[test]
fn classify_severity_split() {
let reg = Registry::from_entries(vec![RegistryEntry {
name: "filesystem".into(),
source: "test".into(),
}]);
let v1 = classify_one("p", "filesytem", ®); match &v1.verdict {
UnknownVerdictKind::LikelyTyposquat { distance, .. } => assert_eq!(*distance, 1),
other => panic!("expected LikelyTyposquat, got {other:?}"),
}
let v2 = classify_one("p", "filesystXY", ®); match &v2.verdict {
UnknownVerdictKind::InformationalTyposquat { distance, .. } => {
assert_eq!(*distance, 2);
}
other => panic!("expected InformationalTyposquat, got {other:?}"),
}
}
#[test]
fn classify_aggregates_dedup_count() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let paths = paths_for(&tmp);
write_claude_json(
&tmp,
r#"{
"mcpServers": {"my-tool": {"command":"x"}},
"projects": {
"/proj1": {"mcpServers": {"my-tool": {"command":"y"}}},
"/proj2": {"mcpServers": {"my-tool": {"command":"z"}}}
}
}"#,
);
let reg = Registry::from_entries(vec![]);
let verdicts = classify(&paths, ®);
let v = verdicts.iter().find(|v| v.name == "my-tool").unwrap();
assert_eq!(v.count, 3);
assert_eq!(v.paths.len(), 3);
assert!(v.paths.windows(2).all(|w| w[0] <= w[1]));
let _ = fs::metadata(tmp.path());
}
}