use super::process::{self, ProcInfo};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
use std::process::Command;
use std::time::SystemTime;
pub const ACTIVE_MTIME_SECS: u64 = 30;
#[derive(Clone, Debug)]
pub struct McpRollout {
pub path: PathBuf,
pub mtime: Option<SystemTime>,
#[allow(dead_code)]
pub size_bytes: u64,
}
impl McpRollout {
pub fn is_active(&self, now: SystemTime, threshold_secs: u64) -> bool {
match self.mtime {
Some(m) => now
.duration_since(m)
.map(|d| d.as_secs() < threshold_secs)
.unwrap_or(false),
None => false,
}
}
}
#[derive(Clone, Debug)]
pub struct McpServer {
pub pid: u32,
#[allow(dead_code)]
pub ppid: u32,
pub parent_cli: &'static str,
#[allow(dead_code)]
pub command: String,
pub profile: Option<String>,
#[allow(dead_code)]
pub mem_kb: u64,
pub rollouts: Vec<McpRollout>,
}
impl McpServer {
pub fn active_count(&self, now: SystemTime, threshold_secs: u64) -> usize {
self.rollouts
.iter()
.filter(|r| r.is_active(now, threshold_secs))
.count()
}
pub fn latest_mtime(&self) -> Option<SystemTime> {
self.rollouts.iter().filter_map(|r| r.mtime).max()
}
}
pub struct McpDetection {
pub servers: Vec<McpServer>,
pub server_pids: HashSet<u32>,
pub owned_rollouts: HashSet<PathBuf>,
}
impl McpDetection {
pub fn empty() -> Self {
Self {
servers: Vec::new(),
server_pids: HashSet::new(),
owned_rollouts: HashSet::new(),
}
}
}
pub fn detect(process_info: &HashMap<u32, ProcInfo>) -> McpDetection {
let server_candidates: Vec<&ProcInfo> = process_info
.values()
.filter(|info| is_codex_mcp_server(&info.command))
.collect();
if server_candidates.is_empty() {
return McpDetection::empty();
}
let pids: Vec<u32> = server_candidates.iter().map(|p| p.pid).collect();
let pid_to_rollouts = map_pid_to_rollouts(&pids);
let mut servers = Vec::with_capacity(server_candidates.len());
let mut owned_rollouts: HashSet<PathBuf> = HashSet::new();
let mut server_pids: HashSet<u32> = HashSet::new();
for info in server_candidates {
let parent_cli = resolve_parent_cli(info.ppid, process_info);
let profile = parse_profile_flag(&info.command);
let mut rollouts: Vec<McpRollout> = pid_to_rollouts
.get(&info.pid)
.map(|paths| paths.iter().map(rollout_for_path).collect())
.unwrap_or_default();
rollouts.sort_by_key(|r| std::cmp::Reverse(r.mtime));
for r in &rollouts {
owned_rollouts.insert(r.path.clone());
}
server_pids.insert(info.pid);
servers.push(McpServer {
pid: info.pid,
ppid: info.ppid,
parent_cli,
command: info.command.clone(),
profile,
mem_kb: info.rss_kb,
rollouts,
});
}
servers.sort_by_key(|s| (s.parent_cli, s.pid));
McpDetection {
servers,
server_pids,
owned_rollouts,
}
}
fn is_codex_mcp_server(cmd: &str) -> bool {
process::cmd_has_binary(cmd, "codex")
&& cmd.contains("mcp-server")
&& !cmd.contains("grep")
&& !cmd.contains("app-server")
}
fn resolve_parent_cli(ppid: u32, process_info: &HashMap<u32, ProcInfo>) -> &'static str {
let Some(parent) = process_info.get(&ppid) else {
return "?";
};
let cmd = &parent.command;
if process::cmd_has_binary(cmd, "claude") {
"claude"
} else if process::cmd_has_binary(cmd, "codex") {
"codex"
} else {
"?"
}
}
fn parse_profile_flag(cmd: &str) -> Option<String> {
let needle = "profile=";
let pos = cmd.find(needle)?;
let tail = &cmd[pos + needle.len()..];
let end = tail.find(|c: char| c.is_whitespace()).unwrap_or(tail.len());
let value = tail[..end].trim_matches(|c: char| c == '"' || c == '\'');
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
fn rollout_for_path(path: &PathBuf) -> McpRollout {
let (mtime, size_bytes) = match std::fs::metadata(path) {
Ok(meta) => (meta.modified().ok(), meta.len()),
Err(_) => (None, 0),
};
McpRollout {
path: path.clone(),
mtime,
size_bytes,
}
}
pub(crate) fn map_pid_to_rollouts(pids: &[u32]) -> HashMap<u32, Vec<PathBuf>> {
let mut map: HashMap<u32, Vec<PathBuf>> = HashMap::new();
if pids.is_empty() {
return map;
}
#[cfg(target_os = "linux")]
{
for &pid in pids {
for target in process::scan_proc_fds(pid) {
if is_rollout_path(&target) {
map.entry(pid).or_default().push(target);
}
}
}
}
#[cfg(target_os = "windows")]
{
let mut sys = sysinfo::System::new();
let pids_sys: Vec<sysinfo::Pid> = pids
.iter()
.copied()
.map(|p| sysinfo::Pid::from(p as usize))
.collect();
sys.refresh_processes_specifics(
sysinfo::ProcessesToUpdate::Some(&pids_sys),
true,
sysinfo::ProcessRefreshKind::new().with_memory(),
);
for &pid_u32 in pids {
let pid = sysinfo::Pid::from(pid_u32 as usize);
if let Some(proc_) = sys.process(pid) {
if let Some(cwd) = proc_.cwd() {
if let Ok(entries) = std::fs::read_dir(cwd) {
for entry in entries.flatten() {
let p = entry.path();
if is_rollout_path(&p) {
map.entry(pid_u32).or_default().push(p);
}
}
}
}
}
}
}
#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))]
{
let pid_args: Vec<String> = pids.iter().map(|p| format!("-p{}", p)).collect();
let mut args = vec!["-F", "pn"];
for pa in &pid_args {
args.push(pa);
}
let output = Command::new("lsof").args(&args).output().ok();
if let Some(output) = output {
let stdout = String::from_utf8_lossy(&output.stdout);
let mut current_pid: Option<u32> = None;
for line in stdout.lines() {
if let Some(pid_str) = line.strip_prefix('p') {
current_pid = pid_str.parse::<u32>().ok();
} else if let Some(name) = line.strip_prefix('n') {
if let Some(pid) = current_pid {
let path = PathBuf::from(name);
if is_rollout_path(&path) {
map.entry(pid).or_default().push(path);
}
}
}
}
}
}
map
}
fn is_rollout_path(p: &Path) -> bool {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("rollout-") && n.ends_with(".jsonl"))
}
#[cfg(test)]
mod tests {
use super::*;
fn proc(pid: u32, ppid: u32, command: &str) -> ProcInfo {
ProcInfo {
pid,
ppid,
rss_kb: 0,
cpu_pct: 0.0,
command: command.to_string(),
}
}
#[test]
fn detects_codex_mcp_server_default_profile() {
let mut info = HashMap::new();
info.insert(100, proc(100, 50, "codex mcp-server"));
info.insert(50, proc(50, 1, "/usr/local/bin/claude --foo"));
let det = detect(&info);
assert_eq!(det.servers.len(), 1);
assert_eq!(det.servers[0].pid, 100);
assert_eq!(det.servers[0].parent_cli, "claude");
assert!(det.servers[0].profile.is_none());
}
#[test]
fn parses_profile_flag() {
let mut info = HashMap::new();
info.insert(
101,
proc(101, 50, "codex mcp-server -c profile=qwen36-litellm"),
);
info.insert(50, proc(50, 1, "claude"));
let det = detect(&info);
assert_eq!(det.servers.len(), 1);
assert_eq!(det.servers[0].profile.as_deref(), Some("qwen36-litellm"));
}
#[test]
fn parent_cli_unknown_when_ppid_missing() {
let mut info = HashMap::new();
info.insert(102, proc(102, 999, "codex mcp-server"));
let det = detect(&info);
assert_eq!(det.servers[0].parent_cli, "?");
}
#[test]
fn ignores_non_mcp_codex_processes() {
let mut info = HashMap::new();
info.insert(103, proc(103, 1, "codex"));
info.insert(104, proc(104, 1, "codex exec something"));
info.insert(105, proc(105, 1, "/path/to/codex --resume xyz"));
let det = detect(&info);
assert!(det.servers.is_empty());
}
#[test]
fn ignores_non_codex_mcp_servers() {
let mut info = HashMap::new();
info.insert(106, proc(106, 1, "/path/to/claude mcp serve"));
let det = detect(&info);
assert!(det.servers.is_empty());
}
#[test]
fn rollout_active_threshold_excludes_old_mtime() {
let now = SystemTime::now();
let stale = McpRollout {
path: PathBuf::from("/x"),
mtime: Some(now - std::time::Duration::from_secs(120)),
size_bytes: 0,
};
let fresh = McpRollout {
path: PathBuf::from("/y"),
mtime: Some(now - std::time::Duration::from_secs(5)),
size_bytes: 0,
};
assert!(!stale.is_active(now, ACTIVE_MTIME_SECS));
assert!(fresh.is_active(now, ACTIVE_MTIME_SECS));
}
}