1use std::collections::BTreeSet;
20use std::fs;
21use std::path::PathBuf;
22
23use serde::{Deserialize, Serialize};
24
25use crate::Paths;
26use crate::registry::Registry;
27
28const TYPOSQUAT_DISTANCE_MAX: usize = 2;
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32pub struct UnknownVerdict {
33 pub path: String,
36 pub name: String,
38 pub verdict: UnknownVerdictKind,
40 pub reason: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
48pub enum UnknownVerdictKind {
49 KnownGood,
51 Typosquat { closest: String, distance: usize },
54 Unknown,
56}
57
58pub fn classify(paths: &Paths, registry: &Registry) -> Vec<UnknownVerdict> {
67 let mut named: BTreeSet<(String, String)> = BTreeSet::new();
68
69 let project_mcp = PathBuf::from(".mcp.json");
71 extract_names(&project_mcp, &mut named, false);
72
73 let home_config = paths.user_home.join(".claude.json");
75 extract_names(&home_config, &mut named, true);
76
77 named
78 .into_iter()
79 .map(|(path, name)| classify_one(&path, &name, registry))
80 .collect()
81}
82
83fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
84 if let Some(entry) = registry.get(name) {
85 return UnknownVerdict {
86 path: path.to_string(),
87 name: name.to_string(),
88 verdict: UnknownVerdictKind::KnownGood,
89 reason: format!("registered (source: {})", entry.source),
90 };
91 }
92 if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
93 return UnknownVerdict {
94 path: path.to_string(),
95 name: name.to_string(),
96 verdict: UnknownVerdictKind::Typosquat {
97 closest: entry.name.clone(),
98 distance,
99 },
100 reason: format!("close to `{}` (distance {})", entry.name, distance),
101 };
102 }
103 UnknownVerdict {
104 path: path.to_string(),
105 name: name.to_string(),
106 verdict: UnknownVerdictKind::Unknown,
107 reason: "not in registry".to_string(),
108 }
109}
110
111fn extract_names(path: &PathBuf, out: &mut BTreeSet<(String, String)>, per_project: bool) {
116 let Ok(body) = fs::read_to_string(path) else {
117 return;
118 };
119 let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) else {
120 return;
121 };
122 let display = path.display().to_string();
123 if let Some(top) = json
124 .get("mcpServers")
125 .and_then(serde_json::Value::as_object)
126 {
127 for name in top.keys() {
128 out.insert((display.clone(), name.clone()));
129 }
130 }
131 if per_project {
132 if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
133 for (proj_name, proj_val) in projects {
134 if let Some(servers) = proj_val
135 .get("mcpServers")
136 .and_then(serde_json::Value::as_object)
137 {
138 for name in servers.keys() {
139 let scoped = format!("{display}#projects.{proj_name}.mcpServers");
140 out.insert((scoped, name.clone()));
141 }
142 }
143 }
144 }
145 }
146}
147
148#[cfg(test)]
149mod tests {
150 use super::*;
151 use crate::registry::RegistryEntry;
152
153 fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
154 std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
155 }
156
157 fn paths_for(tmp: &tempfile::TempDir) -> Paths {
158 Paths {
159 home: tmp.path().to_path_buf(),
160 user_home: tmp.path().to_path_buf(),
161 }
162 }
163
164 fn small_registry() -> Registry {
165 Registry::from_entries(vec![
166 RegistryEntry {
167 name: "filesystem".into(),
168 source: "test-source".into(),
169 },
170 RegistryEntry {
171 name: "github".into(),
172 source: "test-source".into(),
173 },
174 ])
175 }
176
177 #[test]
178 fn known_good_is_recognised() {
179 let tmp = tempfile::tempdir().unwrap();
180 write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
181 let verdicts = classify(&paths_for(&tmp), &small_registry());
182 assert_eq!(verdicts.len(), 1);
183 assert_eq!(verdicts[0].name, "filesystem");
184 assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
185 }
186
187 #[test]
188 fn typosquat_is_flagged() {
189 let tmp = tempfile::tempdir().unwrap();
190 write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
192 let verdicts = classify(&paths_for(&tmp), &small_registry());
193 assert_eq!(verdicts.len(), 1);
194 match &verdicts[0].verdict {
195 UnknownVerdictKind::Typosquat { closest, distance } => {
196 assert_eq!(closest, "filesystem");
197 assert!(*distance > 0 && *distance <= 2);
198 }
199 other => panic!("expected Typosquat, got {other:?}"),
200 }
201 }
202
203 #[test]
204 fn unknown_name_is_unknown() {
205 let tmp = tempfile::tempdir().unwrap();
206 write_claude_json(
207 &tmp,
208 r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
209 );
210 let verdicts = classify(&paths_for(&tmp), &small_registry());
211 assert_eq!(verdicts.len(), 1);
212 assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
213 }
214
215 #[test]
216 fn per_project_servers_are_classified() {
217 let tmp = tempfile::tempdir().unwrap();
218 write_claude_json(
219 &tmp,
220 r#"{
221 "mcpServers": {"github": {"command":"x"}},
222 "projects": {
223 "/some/proj": {
224 "mcpServers": {"my-tool": {"command":"y"}}
225 }
226 }
227 }"#,
228 );
229 let verdicts = classify(&paths_for(&tmp), &small_registry());
230 assert_eq!(verdicts.len(), 2);
231 let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
232 assert!(names.contains(&"github"));
233 assert!(names.contains(&"my-tool"));
234 }
235
236 #[test]
237 fn missing_claude_json_yields_empty_result() {
238 let tmp = tempfile::tempdir().unwrap();
239 let verdicts = classify(&paths_for(&tmp), &small_registry());
240 assert!(verdicts.is_empty());
241 }
242
243 #[test]
244 fn malformed_json_silently_skipped() {
245 let tmp = tempfile::tempdir().unwrap();
246 write_claude_json(&tmp, "not even close to JSON");
247 let verdicts = classify(&paths_for(&tmp), &small_registry());
248 assert!(verdicts.is_empty());
249 }
250}