1use std::time::{Duration, SystemTime};
30
31use forge_primitives::McpServerStatus;
32use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
33
34pub const SCAN_STALENESS: Duration = Duration::from_secs(1);
40
41#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
45pub struct ProcessSnapshot {
46 pub processes: Vec<ProcessEntry>,
50 pub scanned_at: SystemTime,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
60pub struct ProcessEntry {
61 pub pid: u32,
63 pub parent_pid: u32,
66 pub name: String,
70 pub command: String,
74 pub memory_bytes: u64,
77}
78
79pub fn scan(claude_pid: u32, extra_commands: &[String]) -> ProcessSnapshot {
94 let mut system = System::new();
98 system.refresh_processes_specifics(
99 ProcessesToUpdate::All,
100 true,
101 ProcessRefreshKind::nothing().with_memory().with_cmd(sysinfo::UpdateKind::Always),
102 );
103
104 let self_pid = sysinfo::get_current_pid().ok();
105 let candidates = live_candidates(&system, self_pid);
106 let mut processes = select_snapshot(candidates, claude_pid, extra_commands);
107
108 processes.sort_by(|a, b| b.memory_bytes.cmp(&a.memory_bytes).then_with(|| a.pid.cmp(&b.pid)));
112
113 ProcessSnapshot { processes, scanned_at: SystemTime::now() }
114}
115
116fn live_candidates(system: &System, self_pid: Option<Pid>) -> Vec<ProcessEntry> {
120 let mut out = Vec::new();
121 for (pid, proc) in system.processes() {
122 if self_pid == Some(*pid) {
123 continue;
124 }
125 if matches!(proc.status(), sysinfo::ProcessStatus::Zombie | sysinfo::ProcessStatus::Dead) {
126 continue;
127 }
128 let name = proc.name().to_string_lossy().to_string();
129 if name == "ps" || name == "sysinfo" {
130 continue;
131 }
132 let command = proc
133 .cmd()
134 .iter()
135 .map(|arg| arg.to_string_lossy().into_owned())
136 .collect::<Vec<_>>()
137 .join(" ");
138 out.push(ProcessEntry {
139 pid: pid.as_u32(),
140 parent_pid: proc.parent().map_or(0, Pid::as_u32),
141 name,
142 command,
143 memory_bytes: proc.memory(),
144 });
145 }
146 out
147}
148
149fn select_snapshot(
155 candidates: Vec<ProcessEntry>,
156 claude_pid: u32,
157 extra_commands: &[String],
158) -> Vec<ProcessEntry> {
159 let mut children_of: std::collections::HashMap<u32, Vec<u32>> =
162 std::collections::HashMap::new();
163 for entry in &candidates {
164 children_of.entry(entry.parent_pid).or_default().push(entry.pid);
165 }
166
167 let mut included: std::collections::HashSet<u32> = std::collections::HashSet::new();
168 include_subtree(&children_of, claude_pid, &mut included);
170
171 if !extra_commands.is_empty() {
175 let matched_roots: Vec<u32> = candidates
176 .iter()
177 .filter(|entry| !included.contains(&entry.pid))
178 .filter(|entry| extract_inner_command(&entry.command).is_some())
182 .filter(|entry| {
183 extra_commands
184 .iter()
185 .any(|cmd| process_cmdline_matches_tool_input(&entry.command, cmd))
186 })
187 .map(|entry| entry.pid)
188 .collect();
189 for root in matched_roots {
190 if included.insert(root) {
191 include_subtree(&children_of, root, &mut included);
192 }
193 }
194 }
195
196 candidates.into_iter().filter(|entry| included.contains(&entry.pid)).collect()
197}
198
199fn include_subtree(
202 children_of: &std::collections::HashMap<u32, Vec<u32>>,
203 root: u32,
204 included: &mut std::collections::HashSet<u32>,
205) {
206 let mut stack = vec![root];
207 while let Some(pid) = stack.pop() {
208 let Some(children) = children_of.get(&pid) else { continue };
209 for &child in children {
210 if included.insert(child) {
211 stack.push(child);
212 }
213 }
214 }
215}
216
217pub fn extract_inner_command(cmdline: &str) -> Option<String> {
225 let after_eval = cmdline.split_once("eval '")?.1;
226 let inner = after_eval.rsplit_once("' < /dev/null")?.0;
229 Some(inner.trim().replace(r#"'"'"'"#, "'"))
232}
233
234pub fn basename_exe(cmdline: &str) -> String {
240 let cmdline = cmdline.trim();
241 match cmdline.split_once(char::is_whitespace) {
242 Some((exe, rest)) => {
243 let base = exe.rsplit('/').next().unwrap_or(exe);
244 format!("{base} {rest}")
245 }
246 None => cmdline.rsplit('/').next().unwrap_or(cmdline).to_owned(),
247 }
248}
249
250fn normalize_ws(s: &str) -> String {
256 s.split_whitespace().collect::<Vec<_>>().join(" ")
257}
258
259pub fn process_cmdline_matches_tool_input(process_cmd: &str, tool_command: &str) -> bool {
271 let needle = cmdline_needle(tool_command);
272 if needle.is_empty() {
273 return false;
274 }
275 cmdline_haystack(process_cmd).contains(&needle)
276}
277
278pub fn cmdline_needle(tool_command: &str) -> String {
282 normalize_ws(tool_command)
283}
284
285pub fn cmdline_haystack(process_cmd: &str) -> String {
288 let inner = extract_inner_command(process_cmd).unwrap_or_else(|| process_cmd.to_owned());
289 normalize_ws(&inner)
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
294pub struct InfraLabel {
295 pub name: String,
296}
297
298pub fn classify_known_infra(cmdline: &str) -> Option<InfraLabel> {
307 let name = mcp_name_from_npm(cmdline).or_else(|| mcp_name_from_node(cmdline))?;
308 Some(InfraLabel { name })
309}
310
311fn parse_npm_pkg(pkg_spec: &str) -> (Option<&str>, &str) {
316 let (scope, rest) = match pkg_spec.strip_prefix('@') {
317 Some(after_at) => match after_at.split_once('/') {
318 Some((scope, rest)) => (Some(scope), rest),
319 None => (None, pkg_spec),
320 },
321 None => (None, pkg_spec),
322 };
323 let name = rest.split('@').next().unwrap_or(rest);
324 (scope, name)
325}
326
327fn mcp_friendly_name(scope: Option<&str>, name: &str) -> Option<String> {
333 if let Some(base) = name.strip_suffix("-mcp-server").or_else(|| name.strip_suffix("-mcp"))
334 && !base.is_empty()
335 {
336 return Some(base.to_owned());
337 }
338 if name == "mcp" {
339 return scope.filter(|s| !s.is_empty()).map(ToOwned::to_owned);
340 }
341 if let Some(base) = name.strip_prefix("server-")
342 && !base.is_empty()
343 {
344 return Some(base.to_owned());
345 }
346 None
347}
348
349fn mcp_name_from_npm(cmdline: &str) -> Option<String> {
353 let mut toks = cmdline.split_whitespace();
354 if toks.next()? != "npm" || toks.next()? != "exec" {
355 return None;
356 }
357 let (scope, name) = parse_npm_pkg(toks.next()?);
358 mcp_friendly_name(scope, name)
359}
360
361fn mcp_name_from_node(cmdline: &str) -> Option<String> {
365 let mut toks = cmdline.split_whitespace();
366 if !toks.next()?.ends_with("node") {
367 return None;
368 }
369 toks.find_map(|tok| {
370 let base = tok.rsplit('/').next().unwrap_or(tok);
371 let base = base.strip_suffix(".js").unwrap_or(base);
372 let name = base.split('@').next().unwrap_or(base);
373 mcp_friendly_name(None, name)
374 })
375}
376
377#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct ConfiguredMcpServer {
382 pub name: String,
383 pub command: String,
384 pub args: Vec<String>,
385}
386
387pub fn configured_mcp_servers(servers: &[McpServerStatus]) -> Vec<ConfiguredMcpServer> {
392 servers
393 .iter()
394 .filter_map(|server| {
395 let config = server.config.as_ref()?;
396 let command = config.get("command")?.as_str()?.to_owned();
397 let args = config
398 .get("args")
399 .and_then(serde_json::Value::as_array)
400 .map(|arr| arr.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
401 .unwrap_or_default();
402 Some(ConfiguredMcpServer { name: server.name.clone(), command, args })
403 })
404 .collect()
405}
406
407pub fn configured_text_match<'a>(
416 cmdline: &str,
417 configured: &'a [ConfiguredMcpServer],
418) -> Option<&'a ConfiguredMcpServer> {
419 let inner = extract_inner_command(cmdline).unwrap_or_else(|| cmdline.to_owned());
420 let haystack = normalize_ws(&inner);
421 let matched: Vec<&ConfiguredMcpServer> =
422 configured.iter().filter(|server| configured_server_matches(&haystack, server)).collect();
423 match matched.as_slice() {
424 [only] => Some(only),
425 _ => None,
426 }
427}
428
429pub fn resolve_infra_label(
438 cmdline: &str,
439 configured: &[ConfiguredMcpServer],
440) -> Option<InfraLabel> {
441 let inner = extract_inner_command(cmdline).unwrap_or_else(|| cmdline.to_owned());
442 let haystack = normalize_ws(&inner);
443 let matched: Vec<&ConfiguredMcpServer> =
444 configured.iter().filter(|server| configured_server_matches(&haystack, server)).collect();
445 match matched.as_slice() {
446 [only] => Some(InfraLabel { name: strip_plugin_namespace(&only.name).to_owned() }),
447 [] => classify_known_infra(&inner),
448 many => {
449 tracing::debug!(
450 candidates = ?many.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(),
451 cmdline = %cmdline,
452 "MCP process matched multiple configured servers; using package name"
453 );
454 classify_known_infra(&inner)
455 }
456 }
457}
458
459pub fn strip_plugin_namespace(name: &str) -> &str {
464 let mut parts = name.splitn(3, ':');
465 match (parts.next(), parts.next(), parts.next()) {
466 (Some("plugin"), Some(plugin), Some(server))
467 if !plugin.is_empty() && !server.is_empty() =>
468 {
469 server
470 }
471 _ => name,
472 }
473}
474
475#[derive(Debug, Clone, Copy)]
478pub struct RootProcess<'a> {
479 pub pid: u32,
480 pub cmdline: &'a str,
481 pub wire_matched: bool,
482}
483
484const INTERPRETERS: [&str; 7] = ["node", "python", "python3", "deno", "bun", "npx", "uvx"];
495
496fn is_elimination_candidate(cmdline: &str) -> bool {
501 let Some(exe) = cmdline.split_whitespace().next() else {
502 return false;
503 };
504 let base = exe.rsplit('/').next().unwrap_or(exe);
505 INTERPRETERS.contains(&base)
506}
507
508pub fn elect_unmatched_server(
524 servers: &[McpServerStatus],
525 configured: &[ConfiguredMcpServer],
526 roots: &[RootProcess<'_>],
527) -> Option<(u32, String)> {
528 let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
529 let mut candidate = None;
530 let mut candidates = 0usize;
531 for root in roots.iter().filter(|root| !root.wire_matched) {
532 if let Some(label) = resolve_infra_label(root.cmdline, configured) {
533 claimed.insert(label.name);
534 } else if is_elimination_candidate(root.cmdline) {
535 candidates += 1;
536 candidate = Some(root.pid);
537 }
538 }
539 if candidates != 1 {
540 return None;
541 }
542 let mut unmatched = configured
543 .iter()
544 .map(|server| strip_plugin_namespace(&server.name))
545 .filter(|name| !claimed.contains(*name))
546 .filter(|name| {
547 servers.iter().any(|server| {
548 strip_plugin_namespace(&server.name) == *name
549 && server.status == forge_primitives::McpServerConnectionStatus::Connected
550 })
551 });
552 let elected = unmatched.next()?;
553 if unmatched.next().is_some() {
554 return None;
555 }
556 Some((candidate?, elected.to_owned()))
557}
558
559fn configured_server_matches(haystack: &str, server: &ConfiguredMcpServer) -> bool {
568 let mut tokens = server.args.iter().skip_while(|arg| arg.starts_with('-')).peekable();
569 if tokens.peek().is_none() {
570 let command = server.command.as_str();
571 return !command.is_empty() && haystack.contains(command);
572 }
573 tokens.all(|token| !token.is_empty() && haystack.contains(token.as_str()))
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579
580 #[test]
581 fn empty_tool_command_never_matches() {
582 assert!(!process_cmdline_matches_tool_input("any process cmd", ""));
583 assert!(!process_cmdline_matches_tool_input("any process cmd", " "));
584 }
585
586 const REAL_WRAPPER: &str = "/bin/zsh -c source /Users/x/.claude/shell-snapshots/snapshot-zsh-1.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && eval 'gh run watch 123 --exit-status' < /dev/null && pwd -P >| /tmp/claude-ab12-cwd";
594
595 const SINGLE_QUOTE_WRAPPER: &str = r#"/bin/zsh -c source /Users/x/.claude/shell-snapshots/snapshot-zsh-1.sh 2>/dev/null || true && setopt NO_EXTENDED_GLOB NO_BARE_GLOB_QUAL 2>/dev/null || true && eval 'echo '"'"'sq-marker'"'"'; sleep 40' < /dev/null && pwd -P >| /tmp/claude-ab12-cwd"#;
601
602 fn entry(pid: u32, parent_pid: u32, command: &str) -> ProcessEntry {
603 ProcessEntry {
604 pid,
605 parent_pid,
606 name: "bash".to_owned(),
607 command: command.to_owned(),
608 memory_bytes: 1024,
609 }
610 }
611
612 fn selected_pids(entries: &[ProcessEntry]) -> std::collections::BTreeSet<u32> {
613 entries.iter().map(|entry| entry.pid).collect()
614 }
615
616 #[test]
617 fn select_snapshot_keeps_claude_descendants_drops_siblings() {
618 let candidates =
620 vec![entry(200, 100, "cargo build"), entry(300, 200, "rustc"), entry(400, 1, "sshd")];
621 assert_eq!(
622 selected_pids(&select_snapshot(candidates, 100, &[])),
623 [200, 300].into_iter().collect()
624 );
625 }
626
627 #[test]
628 fn select_snapshot_without_commands_drops_detached_bash() {
629 let candidates = vec![entry(200, 100, "cargo build"), entry(500, 1, REAL_WRAPPER)];
633 assert_eq!(
634 selected_pids(&select_snapshot(candidates, 100, &[])),
635 [200].into_iter().collect()
636 );
637 }
638
639 #[test]
640 fn select_snapshot_adopts_command_matched_detached_bash_and_subtree() {
641 let candidates = vec![
645 entry(200, 100, "cargo build"),
646 entry(500, 1, REAL_WRAPPER),
647 entry(600, 500, "gh api repos/foo"),
648 entry(700, 1, "sshd"),
649 ];
650 let extra = vec!["gh run watch 123 --exit-status".to_owned()];
651 assert_eq!(
652 selected_pids(&select_snapshot(candidates, 100, &extra)),
653 [200, 500, 600].into_iter().collect()
654 );
655 }
656
657 #[test]
658 fn select_snapshot_command_match_inside_claude_tree_not_doubled() {
659 let candidates = vec![entry(500, 100, REAL_WRAPPER)];
661 let extra = vec!["gh run watch 123 --exit-status".to_owned()];
662 let selected = select_snapshot(candidates, 100, &extra);
663 assert_eq!(selected.len(), 1);
664 assert_eq!(selected[0].pid, 500);
665 }
666
667 #[test]
668 fn select_snapshot_does_not_adopt_foreign_non_wrapper_match() {
669 let candidates = vec![
673 entry(200, 100, "cargo build"),
674 entry(500, 1, "gh run watch 123 --exit-status --repo busytools/forge"),
675 ];
676 let extra = vec!["gh run watch 123 --exit-status".to_owned()];
677 assert_eq!(
678 selected_pids(&select_snapshot(candidates, 100, &extra)),
679 [200].into_iter().collect()
680 );
681 }
682
683 #[test]
684 fn extract_inner_command_unwraps_real_wrapper() {
685 assert_eq!(
686 extract_inner_command(REAL_WRAPPER).as_deref(),
687 Some("gh run watch 123 --exit-status")
688 );
689 assert_eq!(extract_inner_command("rustc --crate-name forge_tui"), None);
692 assert_eq!(extract_inner_command("node balance-transfer.mjs"), None);
693 }
694
695 #[test]
696 fn substring_match_holds_for_wrapped_shell_invocation() {
697 let tool_command = "gh run watch 123 --exit-status";
699 assert!(process_cmdline_matches_tool_input(REAL_WRAPPER, tool_command));
700 }
701
702 #[test]
703 fn wrapped_multiline_command_matches_after_normalize() {
704 let tool_command = "gh run watch 123\n --exit-status";
709 assert!(process_cmdline_matches_tool_input(REAL_WRAPPER, tool_command));
710 }
711
712 #[test]
713 fn the_split_predicate_matches_the_recorded_pre_change_behaviour() {
714 let cases: [(&str, &str, bool); 10] = [
719 (REAL_WRAPPER, "gh run watch 123 --exit-status", true),
721 (REAL_WRAPPER, "gh run watch 999", false),
723 ("rustc --crate-name forge_tui", "rustc --crate-name forge_tui", true),
725 ("cargo nextest\n run", "cargo nextest run", true),
727 ("cargo nextest run", "cargo\nnextest\nrun", true),
728 ("cargo nextest", "cargo nextest run", false),
730 ("/bin/zsh -c -l source x && eval 'echo hi'", "echo hi", true),
732 (SINGLE_QUOTE_WRAPPER, "echo 'sq-marker'; sleep 40", true),
734 ("anything", "", false),
736 ("anything", " ", false),
737 ];
738 for (process_cmd, tool_command, expected) in cases {
739 let haystack = cmdline_haystack(process_cmd);
740 let needle = cmdline_needle(tool_command);
741 assert_eq!(
742 !needle.is_empty() && haystack.contains(&needle),
743 expected,
744 "haystack {haystack:?} / needle {needle:?} for {process_cmd:?} \
745 vs {tool_command:?}",
746 );
747 assert_eq!(
748 process_cmdline_matches_tool_input(process_cmd, tool_command),
749 expected,
750 "the one-shot predicate drifted from the recorded behaviour for \
751 {process_cmd:?} vs {tool_command:?}",
752 );
753 }
754 }
755
756 #[test]
757 fn a_whitespace_only_command_has_no_needle() {
758 assert_eq!(cmdline_needle(" \n\t "), "");
762 assert_eq!(cmdline_needle(""), "");
763 assert_eq!(cmdline_needle("cargo nextest\n run"), "cargo nextest run");
764 assert_eq!(cmdline_haystack("cargo nextest\n run"), "cargo nextest run");
765 }
766
767 #[test]
768 fn extract_inner_command_unescapes_shell_single_quotes() {
769 assert_eq!(
772 extract_inner_command(SINGLE_QUOTE_WRAPPER).as_deref(),
773 Some("echo 'sq-marker'; sleep 40")
774 );
775 }
776
777 #[test]
778 fn extract_inner_command_terminates_at_outermost_redirect() {
779 let wrapper = format!(
783 "/bin/zsh -c source /x/snap.sh 2>/dev/null || true && eval '{}' < /dev/null && pwd -P >| /tmp/claude-x-cwd",
784 r#"echo '"'"'hi'"'"' < /dev/null"#
785 );
786 assert_eq!(extract_inner_command(&wrapper).as_deref(), Some("echo 'hi' < /dev/null"));
787 }
788
789 #[test]
790 fn single_quote_command_matches_its_wrapper() {
791 assert!(process_cmdline_matches_tool_input(
795 SINGLE_QUOTE_WRAPPER,
796 "echo 'sq-marker'; sleep 40"
797 ));
798 }
799
800 #[test]
801 fn exact_match_holds() {
802 assert!(process_cmdline_matches_tool_input(
803 "cargo build --release",
804 "cargo build --release"
805 ));
806 }
807
808 #[test]
809 fn unrelated_command_does_not_match() {
810 assert!(!process_cmdline_matches_tool_input(
811 "rustc --crate-name forge_tui",
812 "cargo nextest run"
813 ));
814 }
815
816 #[test]
817 fn partial_word_does_not_match_false_positive() {
818 assert!(process_cmdline_matches_tool_input("/usr/bin/cargo build", "cargo"));
824 }
825
826 #[test]
827 fn basename_exe_strips_exe_dir_keeps_args() {
828 assert_eq!(basename_exe("/opt/homebrew/bin/cargo nextest run"), "cargo nextest run");
829 assert_eq!(
830 basename_exe("/Users/x/.rustup/toolchains/nightly/bin/rustc --crate-name forge_tui"),
831 "rustc --crate-name forge_tui"
832 );
833 assert_eq!(
835 basename_exe("/Users/x/proj/target/debug/deps/some_test-9ab1"),
836 "some_test-9ab1"
837 );
838 assert_eq!(basename_exe("postgres: walwriter"), "postgres: walwriter");
840 assert_eq!(basename_exe("cargo build"), "cargo build");
842 assert_eq!(basename_exe(""), "");
844 let _ = basename_exe("/");
845 }
846
847 #[test]
848 fn classify_known_infra_recognizes_npm_exec_mcp() {
849 let c = classify_known_infra("npm exec @upstash/context7-mcp").expect("context7");
851 assert_eq!(c.name, "context7");
852
853 let n = classify_known_infra("npm exec @notionhq/notion-mcp-server").expect("notion");
854 assert_eq!(n.name, "notion");
855
856 assert!(classify_known_infra("npm exec @scope/some-tool").is_none());
858 assert!(classify_known_infra("rustc --crate-name forge_tui").is_none());
859 assert!(classify_known_infra("node balance-transfer.mjs").is_none());
860 }
861
862 #[test]
863 fn classify_known_infra_recognizes_node_launched_mcp() {
864 let l = classify_known_infra("node /opt/foo-mcp-server.js").expect("foo");
865 assert_eq!(l.name, "foo");
866 }
867
868 #[test]
869 fn classify_known_infra_recognizes_broadened_shapes() {
870 assert_eq!(
874 classify_known_infra(
875 "npm exec @playwright/mcp@latest --cdp-endpoint http://localhost:9222"
876 )
877 .expect("playwright")
878 .name,
879 "playwright"
880 );
881 assert_eq!(
882 classify_known_infra("npm exec @playwright/mcp").expect("playwright").name,
883 "playwright"
884 );
885 assert_eq!(
887 classify_known_infra("npm exec @modelcontextprotocol/server-filesystem")
888 .expect("filesystem")
889 .name,
890 "filesystem"
891 );
892 assert_eq!(
894 classify_known_infra("npm exec @upstash/context7-mcp@2.0").expect("context7").name,
895 "context7"
896 );
897 assert_eq!(
900 classify_known_infra(
901 "node /Users/x/.npm/_npx/abc/node_modules/.bin/playwright-mcp --cdp-endpoint http://localhost:9222"
902 )
903 .expect("playwright child")
904 .name,
905 "playwright"
906 );
907 }
908
909 #[test]
910 fn scan_returns_empty_snapshot_for_missing_pid() {
911 let snapshot = scan(0, &[]);
915 let age = SystemTime::now().duration_since(snapshot.scanned_at).map_or(0, |d| d.as_secs());
918 assert!(age <= 5, "scanned_at must be recent");
919 }
920
921 #[test]
922 fn scan_for_self_pid_runs_without_panic() {
923 let me = std::process::id();
928 let _ = scan(me, &[]);
929 }
930
931 fn mcp_status(name: &str, config: Option<serde_json::Value>) -> McpServerStatus {
932 McpServerStatus {
933 name: name.to_owned(),
934 status: forge_primitives::McpServerConnectionStatus::Connected,
935 server_info: None,
936 error: None,
937 config,
938 scope: None,
939 tools: None,
940 sampling_configured: None,
941 sampling_required: None,
942 }
943 }
944
945 fn configured(name: &str, command: &str, args: &[&str]) -> ConfiguredMcpServer {
946 ConfiguredMcpServer {
947 name: name.to_owned(),
948 command: command.to_owned(),
949 args: args.iter().map(|a| (*a).to_owned()).collect(),
950 }
951 }
952
953 fn playwright_pair() -> Vec<ConfiguredMcpServer> {
954 vec![
958 configured(
959 "playwright",
960 "npx",
961 &["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://192.0.2.10:9222"],
962 ),
963 configured(
964 "playwright-local",
965 "npx",
966 &["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://127.0.0.1:9222"],
967 ),
968 ]
969 }
970
971 #[test]
972 fn configured_mcp_servers_extracts_stdio_command_and_args() {
973 let servers = vec![mcp_status(
976 "playwright",
977 Some(serde_json::json!({
978 "type": "stdio",
979 "command": "npx",
980 "args": ["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://192.0.2.10:9222"],
981 "env": {},
982 })),
983 )];
984 assert_eq!(
985 configured_mcp_servers(&servers),
986 vec![configured(
987 "playwright",
988 "npx",
989 &["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://192.0.2.10:9222"],
990 )]
991 );
992 }
993
994 #[test]
995 fn configured_mcp_servers_skips_url_only_and_configless() {
996 let servers = vec![
997 mcp_status("remote", Some(serde_json::json!({"type": "http", "url": "https://x/mcp"}))),
999 mcp_status("bare", None),
1001 ];
1002 assert!(configured_mcp_servers(&servers).is_empty());
1003 }
1004
1005 #[test]
1006 fn resolve_infra_label_uses_configured_name_for_same_package_distinct_args() {
1007 let servers = playwright_pair();
1011 let remote = "npm exec @playwright/mcp@latest --cdp-endpoint http://192.0.2.10:9222";
1012 let local = "npm exec @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9222";
1013 assert_eq!(resolve_infra_label(remote, &servers).expect("remote").name, "playwright");
1014 assert_eq!(resolve_infra_label(local, &servers).expect("local").name, "playwright-local");
1015 }
1016
1017 #[test]
1018 fn resolve_infra_label_falls_back_to_package_name_when_unmatched() {
1019 let ctx = resolve_infra_label("npm exec @upstash/context7-mcp", &playwright_pair())
1022 .expect("context7");
1023 assert_eq!(ctx.name, "context7");
1024 }
1025
1026 #[test]
1027 fn resolve_infra_label_falls_back_on_ambiguous_match() {
1028 let servers = vec![
1031 configured("pw-a", "npx", &["@playwright/mcp@latest"]),
1032 configured("pw-b", "npx", &["@playwright/mcp@latest"]),
1033 ];
1034 let label = resolve_infra_label(
1035 "npm exec @playwright/mcp@latest --cdp-endpoint http://192.0.2.10:9222",
1036 &servers,
1037 )
1038 .expect("playwright");
1039 assert_eq!(label.name, "playwright");
1040 }
1041
1042 #[test]
1043 fn resolve_infra_label_names_local_node_script_by_configured_name() {
1044 let servers = vec![configured("airmail", "node", &["/Users/x/airmail/dist/server.js"])];
1048 let label =
1049 resolve_infra_label("node /Users/x/airmail/dist/server.js", &servers).expect("airmail");
1050 assert_eq!(label.name, "airmail");
1051 }
1052
1053 #[test]
1054 fn resolve_infra_label_is_none_for_non_mcp_process() {
1055 assert!(resolve_infra_label("rustc --crate-name forge_tui", &playwright_pair()).is_none());
1057 }
1058
1059 #[test]
1060 fn resolve_infra_label_without_configured_matches_package_name() {
1061 assert_eq!(
1063 resolve_infra_label(
1064 "npm exec @playwright/mcp@latest --cdp-endpoint http://192.0.2.10:9222",
1065 &[],
1066 )
1067 .expect("playwright")
1068 .name,
1069 "playwright"
1070 );
1071 }
1072
1073 const SHIM: &str = "node /var/folders/0q/q18_z8w555v6z6q3q_3fc_t00000gn/T/tmp.9Vg/shim.mjs";
1077
1078 fn root(pid: u32, cmdline: &str) -> RootProcess<'_> {
1079 RootProcess { pid, cmdline, wire_matched: false }
1080 }
1081
1082 fn airmail_config() -> Vec<ConfiguredMcpServer> {
1084 vec![
1085 configured(
1086 "airmail",
1087 "sh",
1088 &["-c", "shim=$(mktemp -d)/shim.mjs && exec node \"$shim\""],
1089 ),
1090 configured("context7", "npx", &["-y", "@upstash/context7-mcp"]),
1091 ]
1092 }
1093
1094 fn connected(names: &[&str]) -> Vec<McpServerStatus> {
1095 names.iter().map(|name| mcp_status(name, None)).collect()
1096 }
1097
1098 #[test]
1099 fn elects_the_sole_unmatched_connected_server_for_the_sole_candidate() {
1100 let elected = elect_unmatched_server(
1103 &connected(&["airmail", "context7"]),
1104 &airmail_config(),
1105 &[root(100, "npm exec @upstash/context7-mcp"), root(200, SHIM)],
1106 );
1107 assert_eq!(elected, Some((200, "airmail".to_owned())));
1108 }
1109
1110 #[test]
1111 fn declines_when_a_second_server_is_unmatched() {
1112 let elected = elect_unmatched_server(
1115 &connected(&["airmail", "context7"]),
1116 &airmail_config(),
1117 &[root(200, SHIM)],
1118 );
1119 assert_eq!(elected, None);
1120 }
1121
1122 #[test]
1123 fn declines_when_a_second_candidate_process_is_unmatched() {
1124 let elected = elect_unmatched_server(
1125 &connected(&["airmail", "context7"]),
1126 &airmail_config(),
1127 &[
1128 root(100, "npm exec @upstash/context7-mcp"),
1129 root(200, SHIM),
1130 root(300, "node /Users/x/other-service.mjs"),
1131 ],
1132 );
1133 assert_eq!(elected, None);
1134 }
1135
1136 #[test]
1137 fn declines_a_server_that_has_not_handshaken() {
1138 let mut servers = connected(&["airmail", "context7"]);
1141 servers[0].status = forge_primitives::McpServerConnectionStatus::Pending;
1142 let elected = elect_unmatched_server(
1143 &servers,
1144 &airmail_config(),
1145 &[root(100, "npm exec @upstash/context7-mcp"), root(200, SHIM)],
1146 );
1147 assert_eq!(elected, None);
1148 }
1149
1150 #[test]
1151 fn a_transient_tool_process_does_not_spoil_the_election() {
1152 let elected = elect_unmatched_server(
1157 &connected(&["airmail", "context7"]),
1158 &airmail_config(),
1159 &[
1160 root(100, "npm exec @upstash/context7-mcp"),
1161 root(200, SHIM),
1162 root(300, "rg --json -e pattern /Users/x/Projects"),
1163 ],
1164 );
1165 assert_eq!(elected, Some((200, "airmail".to_owned())));
1166 }
1167
1168 #[test]
1169 fn a_shell_spawned_child_of_claude_does_not_spoil_the_election() {
1170 let wrapper = "/bin/bash -c source /Users/x/.claude/snap.sh 2>/dev/null || true && eval 'cargo build' < /dev/null && pwd -P >| /tmp/claude-ab12-cwd";
1176 let elected = elect_unmatched_server(
1177 &connected(&["airmail", "context7"]),
1178 &airmail_config(),
1179 &[root(100, "npm exec @upstash/context7-mcp"), root(200, SHIM), root(400, wrapper)],
1180 );
1181 assert_eq!(elected, Some((200, "airmail".to_owned())));
1182 }
1183
1184 #[test]
1185 fn a_wire_matched_process_is_never_a_candidate() {
1186 let elected = elect_unmatched_server(
1189 &connected(&["airmail"]),
1190 &airmail_config()[..1],
1191 &[RootProcess { pid: 200, cmdline: "node build.mjs", wire_matched: true }],
1192 );
1193 assert_eq!(elected, None);
1194 }
1195
1196 #[test]
1197 fn configured_mcp_servers_extracts_command_only_server() {
1198 let servers =
1200 vec![mcp_status("fs", Some(serde_json::json!({"type": "stdio", "command": "mcp-fs"})))];
1201 assert_eq!(configured_mcp_servers(&servers), vec![configured("fs", "mcp-fs", &[])]);
1202 }
1203
1204 #[test]
1205 fn resolve_infra_label_matches_bare_command_server() {
1206 let servers = vec![configured("ctx-custom", "context7-mcp", &[])];
1209 let label = resolve_infra_label("npm exec @upstash/context7-mcp", &servers).expect("ctx");
1210 assert_eq!(label.name, "ctx-custom");
1211 }
1212
1213 #[test]
1214 fn resolve_infra_label_near_miss_falls_back_to_package() {
1215 let servers = vec![configured(
1220 "pw-remote",
1221 "npx",
1222 &["-y", "@playwright/mcp@latest", "--cdp-endpoint", "http://192.0.2.10:9222"],
1223 )];
1224 let label = resolve_infra_label(
1225 "npm exec @playwright/mcp@latest --cdp-endpoint http://127.0.0.1:9222",
1226 &servers,
1227 )
1228 .expect("pw");
1229 assert_eq!(label.name, "playwright");
1230 }
1231
1232 #[test]
1233 fn resolve_infra_label_strips_plugin_namespace_from_configured_name() {
1234 let servers =
1239 vec![configured("plugin:context7:context7", "npx", &["-y", "@upstash/context7-mcp"])];
1240 assert_eq!(
1241 resolve_infra_label("npm exec @upstash/context7-mcp", &servers).expect("ctx").name,
1242 "context7",
1243 );
1244 }
1245
1246 #[test]
1247 fn resolve_infra_label_keeps_plain_configured_name_with_a_colon() {
1248 let servers = vec![configured("ctx:7", "npx", &["@upstash/context7-mcp"])];
1251 assert_eq!(
1252 resolve_infra_label("npm exec @upstash/context7-mcp", &servers).expect("ctx").name,
1253 "ctx:7",
1254 );
1255 }
1256
1257 #[test]
1258 fn resolve_infra_label_empty_config_does_not_match_everything() {
1259 let servers = vec![configured("blank", "", &[])];
1262 let label = resolve_infra_label("npm exec @upstash/context7-mcp", &servers).expect("ctx");
1263 assert_eq!(label.name, "context7");
1264 }
1265}