1use std::collections::{BTreeMap, BTreeSet};
28use std::fs;
29use std::path::PathBuf;
30
31use serde::{Deserialize, Serialize};
32
33use crate::Paths;
34use crate::registry::Registry;
35
36const TYPOSQUAT_DISTANCE_MAX: usize = 2;
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43pub struct UnknownVerdict {
44 pub name: String,
46 pub verdict: UnknownVerdictKind,
48 pub reason: String,
52 pub count: usize,
54 pub paths: Vec<String>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub enum UnknownVerdictKind {
61 KnownGood,
63 LikelyTyposquat { closest: String, distance: usize },
65 InformationalTyposquat { closest: String, distance: usize },
68 Unknown,
70}
71
72pub fn classify(paths: &Paths, registry: &Registry) -> Vec<UnknownVerdict> {
81 let mut named: BTreeSet<(String, String)> = BTreeSet::new();
83
84 let project_mcp = PathBuf::from(".mcp.json");
86 extract_names(&project_mcp, &mut named, false);
87
88 let home_config = paths.user_home.join(".claude.json");
90 extract_names(&home_config, &mut named, true);
91
92 let mut by_name: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
94 for (path, name) in named {
95 by_name.entry(name).or_default().insert(path);
96 }
97
98 by_name
99 .into_iter()
100 .map(|(name, path_set)| {
101 let paths_vec: Vec<String> = path_set.into_iter().collect();
102 let count = paths_vec.len();
103 let rep = paths_vec.first().map_or("", String::as_str);
106 let mut v = classify_one(rep, &name, registry);
107 v.count = count;
108 v.paths = paths_vec;
109 v
110 })
111 .collect()
112}
113
114fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
115 if let Some(entry) = registry.get(name) {
116 return UnknownVerdict {
117 name: name.to_string(),
118 verdict: UnknownVerdictKind::KnownGood,
119 reason: format!("registered (source: {})", entry.source),
120 count: 1,
121 paths: vec![path.to_string()],
122 };
123 }
124 if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
125 let char_count = name.chars().count();
128 if distance == 2 && char_count <= 3 {
129 } else {
131 let verdict = if distance == 1 {
132 UnknownVerdictKind::LikelyTyposquat {
133 closest: entry.name.clone(),
134 distance,
135 }
136 } else {
137 UnknownVerdictKind::InformationalTyposquat {
138 closest: entry.name.clone(),
139 distance,
140 }
141 };
142 return UnknownVerdict {
143 name: name.to_string(),
144 verdict,
145 reason: format!("close to `{}` (distance {})", entry.name, distance),
146 count: 1,
147 paths: vec![path.to_string()],
148 };
149 }
150 }
151 UnknownVerdict {
152 name: name.to_string(),
153 verdict: UnknownVerdictKind::Unknown,
154 reason: "not in registry".to_string(),
155 count: 1,
156 paths: vec![path.to_string()],
157 }
158}
159
160fn extract_names(path: &PathBuf, out: &mut BTreeSet<(String, String)>, per_project: bool) {
165 let Ok(body) = fs::read_to_string(path) else {
166 return;
167 };
168 let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) else {
169 return;
170 };
171 let display = path.display().to_string();
172 if let Some(top) = json
173 .get("mcpServers")
174 .and_then(serde_json::Value::as_object)
175 {
176 for name in top.keys() {
177 out.insert((display.clone(), name.clone()));
178 }
179 }
180 if per_project {
181 if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object) {
182 for (proj_name, proj_val) in projects {
183 if let Some(servers) = proj_val
184 .get("mcpServers")
185 .and_then(serde_json::Value::as_object)
186 {
187 for name in servers.keys() {
188 let scoped = format!("{display}#projects.{proj_name}.mcpServers");
189 out.insert((scoped, name.clone()));
190 }
191 }
192 }
193 }
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::registry::RegistryEntry;
201
202 fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
203 std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
204 }
205
206 fn paths_for(tmp: &tempfile::TempDir) -> Paths {
207 Paths {
208 home: tmp.path().to_path_buf(),
209 user_home: tmp.path().to_path_buf(),
210 }
211 }
212
213 fn small_registry() -> Registry {
214 Registry::from_entries(vec![
215 RegistryEntry {
216 name: "filesystem".into(),
217 source: "test-source".into(),
218 },
219 RegistryEntry {
220 name: "github".into(),
221 source: "test-source".into(),
222 },
223 ])
224 }
225
226 #[test]
227 fn known_good_is_recognised() {
228 let tmp = tempfile::tempdir().unwrap();
229 write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
230 let verdicts = classify(&paths_for(&tmp), &small_registry());
231 assert_eq!(verdicts.len(), 1);
232 assert_eq!(verdicts[0].name, "filesystem");
233 assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
234 }
235
236 #[test]
237 fn typosquat_is_flagged() {
238 let tmp = tempfile::tempdir().unwrap();
239 write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
242 let verdicts = classify(&paths_for(&tmp), &small_registry());
243 assert_eq!(verdicts.len(), 1);
244 match &verdicts[0].verdict {
245 UnknownVerdictKind::InformationalTyposquat { closest, distance } => {
246 assert_eq!(closest, "filesystem");
247 assert_eq!(*distance, 2);
248 }
249 other => panic!("expected InformationalTyposquat, got {other:?}"),
250 }
251 }
252
253 #[test]
254 fn unknown_name_is_unknown() {
255 let tmp = tempfile::tempdir().unwrap();
256 write_claude_json(
257 &tmp,
258 r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
259 );
260 let verdicts = classify(&paths_for(&tmp), &small_registry());
261 assert_eq!(verdicts.len(), 1);
262 assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
263 }
264
265 #[test]
266 fn per_project_servers_are_classified() {
267 let tmp = tempfile::tempdir().unwrap();
268 write_claude_json(
269 &tmp,
270 r#"{
271 "mcpServers": {"github": {"command":"x"}},
272 "projects": {
273 "/some/proj": {
274 "mcpServers": {"my-tool": {"command":"y"}}
275 }
276 }
277 }"#,
278 );
279 let verdicts = classify(&paths_for(&tmp), &small_registry());
280 assert_eq!(verdicts.len(), 2);
281 let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
282 assert!(names.contains(&"github"));
283 assert!(names.contains(&"my-tool"));
284 }
285
286 #[test]
287 fn missing_claude_json_yields_empty_result() {
288 let tmp = tempfile::tempdir().unwrap();
289 let verdicts = classify(&paths_for(&tmp), &small_registry());
290 assert!(verdicts.is_empty());
291 }
292
293 #[test]
294 fn malformed_json_silently_skipped() {
295 let tmp = tempfile::tempdir().unwrap();
296 write_claude_json(&tmp, "not even close to JSON");
297 let verdicts = classify(&paths_for(&tmp), &small_registry());
298 assert!(verdicts.is_empty());
299 }
300
301 #[test]
304 fn classify_one_short_name_distance_2_suppressed() {
305 let reg = Registry::from_entries(vec![RegistryEntry {
309 name: "liv".into(),
310 source: "test".into(),
311 }]);
312 let reg2 = Registry::from_entries(vec![RegistryEntry {
316 name: "git".into(),
317 source: "test".into(),
318 }]);
319 let reg3 = Registry::from_entries(vec![RegistryEntry {
322 name: "ab".into(),
323 source: "test".into(),
324 }]);
325 let v = classify_one("path", "cd", ®3);
326 assert_eq!(v.verdict, UnknownVerdictKind::Unknown);
328
329 let reg4 = Registry::from_entries(vec![RegistryEntry {
332 name: "abc".into(),
333 source: "test".into(),
334 }]);
335 let v2 = classify_one("path", "axz", ®4);
337 assert_eq!(v2.verdict, UnknownVerdictKind::Unknown);
338
339 let _ = reg;
341 let _ = reg2;
342 }
343
344 #[test]
345 fn classify_severity_split() {
346 let reg = Registry::from_entries(vec![RegistryEntry {
347 name: "filesystem".into(),
348 source: "test".into(),
349 }]);
350 let v1 = classify_one("p", "filesytem", ®); match &v1.verdict {
353 UnknownVerdictKind::LikelyTyposquat { distance, .. } => assert_eq!(*distance, 1),
354 other => panic!("expected LikelyTyposquat, got {other:?}"),
355 }
356 let v2 = classify_one("p", "filesystXY", ®); match &v2.verdict {
359 UnknownVerdictKind::InformationalTyposquat { distance, .. } => {
360 assert_eq!(*distance, 2);
361 }
362 other => panic!("expected InformationalTyposquat, got {other:?}"),
363 }
364 }
365
366 #[test]
367 fn classify_aggregates_dedup_count() {
368 use std::fs;
369 let tmp = tempfile::tempdir().unwrap();
370 let paths = paths_for(&tmp);
371 write_claude_json(
373 &tmp,
374 r#"{
375 "mcpServers": {"my-tool": {"command":"x"}},
376 "projects": {
377 "/proj1": {"mcpServers": {"my-tool": {"command":"y"}}},
378 "/proj2": {"mcpServers": {"my-tool": {"command":"z"}}}
379 }
380 }"#,
381 );
382 let reg = Registry::from_entries(vec![]);
383 let verdicts = classify(&paths, ®);
384 let v = verdicts.iter().find(|v| v.name == "my-tool").unwrap();
386 assert_eq!(v.count, 3);
388 assert_eq!(v.paths.len(), 3);
389 assert!(v.paths.windows(2).all(|w| w[0] <= w[1]));
391
392 let _ = fs::metadata(tmp.path());
394 }
395}