1use std::collections::{BTreeMap, BTreeSet};
30use std::fs;
31
32use serde::{Deserialize, Serialize};
33
34use crate::Paths;
35use crate::platform::PlatformProbe;
36use crate::registry::Registry;
37
38const TYPOSQUAT_DISTANCE_MAX: usize = 2;
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct UnknownVerdict {
46 pub name: String,
48 pub verdict: UnknownVerdictKind,
50 pub reason: String,
54 pub count: usize,
56 pub paths: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
62pub enum UnknownVerdictKind {
63 KnownGood,
65 LikelyTyposquat { closest: String, distance: usize },
67 InformationalTyposquat { closest: String, distance: usize },
70 Unknown,
72}
73
74pub fn classify(
84 paths: &Paths,
85 registry: &Registry,
86 probes: &[&dyn PlatformProbe],
87) -> Vec<UnknownVerdict> {
88 let mut named: BTreeSet<(String, String)> = BTreeSet::new();
90
91 for probe in probes {
97 for path in probe.mcp_config_paths(paths) {
98 let Ok(body) = fs::read_to_string(&path) else {
99 continue;
100 };
101 let Ok(entries) = probe.extract_mcp_servers(&body, &path) else {
102 continue;
103 };
104 for entry in entries {
105 named.insert((entry.display_path, entry.name));
106 }
107 }
108 }
109
110 let mut by_name: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
112 for (path, name) in named {
113 by_name.entry(name).or_default().insert(path);
114 }
115
116 by_name
117 .into_iter()
118 .map(|(name, path_set)| {
119 let paths_vec: Vec<String> = path_set.into_iter().collect();
120 let count = paths_vec.len();
121 let rep = paths_vec.first().map_or("", String::as_str);
124 let mut v = classify_one(rep, &name, registry);
125 v.count = count;
126 v.paths = paths_vec;
127 v
128 })
129 .collect()
130}
131
132fn classify_one(path: &str, name: &str, registry: &Registry) -> UnknownVerdict {
133 if let Some(entry) = registry.get(name) {
134 return UnknownVerdict {
135 name: name.to_string(),
136 verdict: UnknownVerdictKind::KnownGood,
137 reason: format!("registered (source: {})", entry.source),
138 count: 1,
139 paths: vec![path.to_string()],
140 };
141 }
142 if let Some((entry, distance)) = registry.closest(name, TYPOSQUAT_DISTANCE_MAX) {
143 let char_count = name.chars().count();
146 if distance == 2 && char_count <= 3 {
147 } else {
149 let verdict = if distance == 1 {
150 UnknownVerdictKind::LikelyTyposquat {
151 closest: entry.name.clone(),
152 distance,
153 }
154 } else {
155 UnknownVerdictKind::InformationalTyposquat {
156 closest: entry.name.clone(),
157 distance,
158 }
159 };
160 return UnknownVerdict {
161 name: name.to_string(),
162 verdict,
163 reason: format!("close to `{}` (distance {})", entry.name, distance),
164 count: 1,
165 paths: vec![path.to_string()],
166 };
167 }
168 }
169 UnknownVerdict {
170 name: name.to_string(),
171 verdict: UnknownVerdictKind::Unknown,
172 reason: "not in registry".to_string(),
173 count: 1,
174 paths: vec![path.to_string()],
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::platform::McpServerEntry;
182 use crate::registry::RegistryEntry;
183 use std::path::{Path, PathBuf};
184
185 struct TestClaudeProbe;
193
194 impl PlatformProbe for TestClaudeProbe {
195 fn id(&self) -> &'static str {
196 "claude-code-test"
197 }
198 fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
199 Vec::new()
200 }
201 fn mcp_config_paths(&self, paths: &Paths) -> Vec<PathBuf> {
202 vec![
203 paths.user_home.join(".claude.json"),
204 PathBuf::from(".mcp.json"),
205 ]
206 }
207 fn extract_mcp_servers(
208 &self,
209 content: &str,
210 path: &Path,
211 ) -> crate::error::Result<Vec<McpServerEntry>> {
212 let json: serde_json::Value = serde_json::from_str(content)?;
213 let display = path.display().to_string();
214 let mut out = Vec::new();
215 if let Some(top) = json
216 .get("mcpServers")
217 .and_then(serde_json::Value::as_object)
218 {
219 for name in top.keys() {
220 out.push(McpServerEntry {
221 name: name.clone(),
222 display_path: display.clone(),
223 });
224 }
225 }
226 if path
227 .file_name()
228 .and_then(|f| f.to_str())
229 .is_some_and(|n| n == ".claude.json")
230 {
231 if let Some(projects) = json.get("projects").and_then(serde_json::Value::as_object)
232 {
233 for (proj_name, proj_val) in projects {
234 if let Some(servers) = proj_val
235 .get("mcpServers")
236 .and_then(serde_json::Value::as_object)
237 {
238 for name in servers.keys() {
239 let scoped = format!("{display}#projects.{proj_name}.mcpServers");
240 out.push(McpServerEntry {
241 name: name.clone(),
242 display_path: scoped,
243 });
244 }
245 }
246 }
247 }
248 }
249 Ok(out)
250 }
251 }
252
253 fn claude_only(probe: &TestClaudeProbe) -> [&dyn PlatformProbe; 1] {
254 [probe as &dyn PlatformProbe]
255 }
256
257 fn write_claude_json(tmp: &tempfile::TempDir, body: &str) {
258 std::fs::write(tmp.path().join(".claude.json"), body).unwrap();
259 }
260
261 fn paths_for(tmp: &tempfile::TempDir) -> Paths {
262 Paths {
263 home: tmp.path().to_path_buf(),
264 user_home: tmp.path().to_path_buf(),
265 }
266 }
267
268 fn small_registry() -> Registry {
269 Registry::from_entries(vec![
270 RegistryEntry {
271 name: "filesystem".into(),
272 source: "test-source".into(),
273 },
274 RegistryEntry {
275 name: "github".into(),
276 source: "test-source".into(),
277 },
278 ])
279 }
280
281 #[test]
282 fn known_good_is_recognised() {
283 let tmp = tempfile::tempdir().unwrap();
284 write_claude_json(&tmp, r#"{"mcpServers":{"filesystem":{"command":"x"}}}"#);
285 let verdicts = classify(
286 &paths_for(&tmp),
287 &small_registry(),
288 &claude_only(&TestClaudeProbe),
289 );
290 assert_eq!(verdicts.len(), 1);
291 assert_eq!(verdicts[0].name, "filesystem");
292 assert_eq!(verdicts[0].verdict, UnknownVerdictKind::KnownGood);
293 }
294
295 #[test]
296 fn typosquat_is_flagged() {
297 let tmp = tempfile::tempdir().unwrap();
298 write_claude_json(&tmp, r#"{"mcpServers":{"filesystme":{"command":"x"}}}"#);
301 let verdicts = classify(
302 &paths_for(&tmp),
303 &small_registry(),
304 &claude_only(&TestClaudeProbe),
305 );
306 assert_eq!(verdicts.len(), 1);
307 match &verdicts[0].verdict {
308 UnknownVerdictKind::InformationalTyposquat { closest, distance } => {
309 assert_eq!(closest, "filesystem");
310 assert_eq!(*distance, 2);
311 }
312 other => panic!("expected InformationalTyposquat, got {other:?}"),
313 }
314 }
315
316 #[test]
317 fn unknown_name_is_unknown() {
318 let tmp = tempfile::tempdir().unwrap();
319 write_claude_json(
320 &tmp,
321 r#"{"mcpServers":{"my-private-tool":{"command":"x"}}}"#,
322 );
323 let verdicts = classify(
324 &paths_for(&tmp),
325 &small_registry(),
326 &claude_only(&TestClaudeProbe),
327 );
328 assert_eq!(verdicts.len(), 1);
329 assert_eq!(verdicts[0].verdict, UnknownVerdictKind::Unknown);
330 }
331
332 #[test]
333 fn per_project_servers_are_classified() {
334 let tmp = tempfile::tempdir().unwrap();
335 write_claude_json(
336 &tmp,
337 r#"{
338 "mcpServers": {"github": {"command":"x"}},
339 "projects": {
340 "/some/proj": {
341 "mcpServers": {"my-tool": {"command":"y"}}
342 }
343 }
344 }"#,
345 );
346 let verdicts = classify(
347 &paths_for(&tmp),
348 &small_registry(),
349 &claude_only(&TestClaudeProbe),
350 );
351 assert_eq!(verdicts.len(), 2);
352 let names: Vec<&str> = verdicts.iter().map(|v| v.name.as_str()).collect();
353 assert!(names.contains(&"github"));
354 assert!(names.contains(&"my-tool"));
355 }
356
357 #[test]
358 fn missing_claude_json_yields_empty_result() {
359 let tmp = tempfile::tempdir().unwrap();
360 let verdicts = classify(
361 &paths_for(&tmp),
362 &small_registry(),
363 &claude_only(&TestClaudeProbe),
364 );
365 assert!(verdicts.is_empty());
366 }
367
368 #[test]
369 fn malformed_json_silently_skipped() {
370 let tmp = tempfile::tempdir().unwrap();
371 write_claude_json(&tmp, "not even close to JSON");
372 let verdicts = classify(
373 &paths_for(&tmp),
374 &small_registry(),
375 &claude_only(&TestClaudeProbe),
376 );
377 assert!(verdicts.is_empty());
378 }
379
380 #[test]
383 fn classify_one_short_name_distance_2_suppressed() {
384 let reg = Registry::from_entries(vec![RegistryEntry {
388 name: "liv".into(),
389 source: "test".into(),
390 }]);
391 let reg2 = Registry::from_entries(vec![RegistryEntry {
395 name: "git".into(),
396 source: "test".into(),
397 }]);
398 let reg3 = Registry::from_entries(vec![RegistryEntry {
401 name: "ab".into(),
402 source: "test".into(),
403 }]);
404 let v = classify_one("path", "cd", ®3);
405 assert_eq!(v.verdict, UnknownVerdictKind::Unknown);
407
408 let reg4 = Registry::from_entries(vec![RegistryEntry {
411 name: "abc".into(),
412 source: "test".into(),
413 }]);
414 let v2 = classify_one("path", "axz", ®4);
416 assert_eq!(v2.verdict, UnknownVerdictKind::Unknown);
417
418 let _ = reg;
420 let _ = reg2;
421 }
422
423 #[test]
424 fn classify_severity_split() {
425 let reg = Registry::from_entries(vec![RegistryEntry {
426 name: "filesystem".into(),
427 source: "test".into(),
428 }]);
429 let v1 = classify_one("p", "filesytem", ®); match &v1.verdict {
432 UnknownVerdictKind::LikelyTyposquat { distance, .. } => assert_eq!(*distance, 1),
433 other => panic!("expected LikelyTyposquat, got {other:?}"),
434 }
435 let v2 = classify_one("p", "filesystXY", ®); match &v2.verdict {
438 UnknownVerdictKind::InformationalTyposquat { distance, .. } => {
439 assert_eq!(*distance, 2);
440 }
441 other => panic!("expected InformationalTyposquat, got {other:?}"),
442 }
443 }
444
445 #[test]
446 fn classify_aggregates_dedup_count() {
447 use std::fs;
448 let tmp = tempfile::tempdir().unwrap();
449 let paths = paths_for(&tmp);
450 write_claude_json(
452 &tmp,
453 r#"{
454 "mcpServers": {"my-tool": {"command":"x"}},
455 "projects": {
456 "/proj1": {"mcpServers": {"my-tool": {"command":"y"}}},
457 "/proj2": {"mcpServers": {"my-tool": {"command":"z"}}}
458 }
459 }"#,
460 );
461 let reg = Registry::from_entries(vec![]);
462 let verdicts = classify(&paths, ®, &claude_only(&TestClaudeProbe));
463 let v = verdicts.iter().find(|v| v.name == "my-tool").unwrap();
465 assert_eq!(v.count, 3);
467 assert_eq!(v.paths.len(), 3);
468 assert!(v.paths.windows(2).all(|w| w[0] <= w[1]));
470
471 let _ = fs::metadata(tmp.path());
473 }
474}