From 3821309a1adb1f62752060679978232ec9298ebc Mon Sep 17 00:00:00 2001 From: Mapika Date: Sat, 1 Aug 2026 09:17:54 +0200 Subject: [PATCH] feat(mcp): report descendant processes in inspect_port Answers what else stops when you stop a port: a dev server's workers are its children, not separate port owners. Entries are flat and carry ppid and depth, which holds what a nested structure would and is simpler to consume. The walk is breadth-first and bounded, so a truncated result keeps the shallowest part of the tree, and says that it truncated. --- CHANGELOG.md | 8 +++ src/linux.rs | 16 ++++++ src/macos.rs | 38 +++++++++++++ src/mcp.rs | 150 +++++++++++++++++++++++++++++++++++++++++++++++-- src/windows.rs | 37 ++++++++++++ 5 files changed, 243 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1335b8..f82050d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ### Added +- The MCP `inspect_port` tool now returns each process's descendants in + `child_processes`, so "what else stops if I stop this?" is answerable — a dev + server's workers are its children, not separate port owners. Entries are flat + and carry `pid`, `ppid`, `process`, and `depth`, which holds the same + information as a nested structure and is simpler to consume. The walk is + bounded and reports `child_processes_truncated` rather than silently + returning a partial tree. This is distinct from the existing `children` + field, which is a count of direct children only. - New MCP tool **`diff_ports`**, which answers "what did that actually change?" The first call records a baseline; the next reports what opened, closed, or changed owner since. A port whose PID changed is reported as *replaced* diff --git a/src/linux.rs b/src/linux.rs index f25ee46..a0d003f 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -220,6 +220,22 @@ fn exe_link_name(path: &std::path::Path) -> Option { } } +/// Direct children of `pid`, with their names. +/// +/// Name and PID come back together because Windows gets both from one process +/// snapshot; splitting them would make that platform pay for a second walk. +pub fn get_child_processes(pid: u32) -> Vec<(u32, String)> { + // The kernel exposes children per *thread*, and the main thread's list is + // the process's own. Unreadable for another user's process, which degrades + // to an empty list rather than an error — as every other field here does. + let raw = + fs::read_to_string(format!("/proc/{}/task/{}/children", pid, pid)).unwrap_or_default(); + raw.split_whitespace() + .filter_map(|s| s.parse::().ok()) + .map(|child| (child, get_process_name(child))) + .collect() +} + fn get_process_cmdline(pid: u32) -> String { let raw = fs::read(format!("/proc/{}/cmdline", pid)).unwrap_or_default(); let cmd: String = raw diff --git a/src/macos.rs b/src/macos.rs index a517f12..5d36c18 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -355,6 +355,44 @@ fn count_children(pid: i32) -> u32 { count as u32 } +/// Direct children of `pid`, with their names. +/// +/// Name and PID come back together because Windows gets both from one process +/// snapshot; splitting them would make that platform pay for a second walk. +pub fn get_child_processes(pid: u32) -> Vec<(u32, String)> { + let pid = pid as i32; + let size = unsafe { proc_listchildpids(pid, std::ptr::null_mut(), 0) }; + if size <= 0 { + return Vec::new(); + } + + // Ask for a little more than the sizing call reported: children can be + // forked between the two calls, and a full buffer would silently truncate. + let capacity = size as usize / std::mem::size_of::() + 8; + let mut buf = vec![0i32; capacity]; + let bytes = unsafe { + proc_listchildpids( + pid, + buf.as_mut_ptr() as *mut libc::c_void, + (capacity * std::mem::size_of::()) as i32, + ) + }; + if bytes <= 0 { + return Vec::new(); + } + + let written = (bytes as usize / std::mem::size_of::()).min(capacity); + buf.truncate(written); + buf.into_iter() + .filter(|p| *p > 0) + .map(|child| { + let path = get_pid_path(child); + let name = path.rsplit('/').next().unwrap_or("").to_string(); + (child as u32, name) + }) + .collect() +} + pub fn get_process_cwd(_pid: u32) -> String { String::new() } diff --git a/src/mcp.rs b/src/mcp.rs index aaddaa4..7e49d14 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -27,11 +27,11 @@ use crate::{ use crate::docker::{DockerPortMap, get_docker_port_map}; #[cfg(target_os = "linux")] -use crate::linux::get_port_infos; +use crate::linux::{get_child_processes, get_port_infos}; #[cfg(target_os = "macos")] -use crate::macos::get_port_infos; +use crate::macos::{get_child_processes, get_port_infos}; #[cfg(target_os = "windows")] -use crate::windows::get_port_infos; +use crate::windows::{get_child_processes, get_port_infos}; /// Newest spec revision we implement. const LATEST_PROTOCOL: &str = "2025-11-25"; @@ -184,7 +184,7 @@ fn initialize_result(params: &str) -> String { /// Read-only tools, always available. const SAFE_TOOLS: &[&str] = &[ r#"{"name":"list_ports","title":"List ports","description":"List listening ports on this machine with the process, user, uptime, memory, and full command behind each one. Set all=true to include established/non-listening connections, docker=true to attribute ports to Docker containers. Returns a JSON array.","inputSchema":{"type":"object","properties":{"all":{"type":"boolean","description":"Include non-listening connections (ESTABLISHED, TIME_WAIT, ...). Default false."},"docker":{"type":"boolean","description":"Attribute ports to Docker containers and include container-only published ports. Default false."}}},"annotations":{"title":"List ports","readOnlyHint":true,"openWorldHint":false}}"#, - r#"{"name":"inspect_port","title":"Inspect a port","description":"Inspect a single port in detail. Returns every process bound to it, including the working directory of each. Use when the user names a specific port, e.g. 'what is on 3000?'.","inputSchema":{"type":"object","properties":{"port":{"type":"integer","description":"Port number (1-65535)."},"docker":{"type":"boolean","description":"Include Docker container attribution. Default false."}},"required":["port"]},"annotations":{"title":"Inspect a port","readOnlyHint":true,"openWorldHint":false}}"#, + r#"{"name":"inspect_port","title":"Inspect a port","description":"Inspect a single port in detail. Returns every process bound to it, with the working directory of each and its descendant processes in child_processes (pid, ppid, process, depth — flat, so nest by ppid if you need a tree). Use when the user names a specific port, e.g. 'what is on 3000?', or to see what else would be affected by stopping it: a dev server's workers are its children, not separate port owners. child_processes_truncated is true when the walk hit its limit.","inputSchema":{"type":"object","properties":{"port":{"type":"integer","description":"Port number (1-65535)."},"docker":{"type":"boolean","description":"Include Docker container attribution. Default false."}},"required":["port"]},"annotations":{"title":"Inspect a port","readOnlyHint":true,"openWorldHint":false}}"#, r#"{"name":"find_process","title":"Find ports by process","description":"Find which ports a process is listening on, matching by process name or command substring (case-insensitive). Use for questions like 'what port is postgres on?'. Returns a JSON array.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"Process name or command substring, e.g. 'node', 'postgres'."},"all":{"type":"boolean","description":"Include non-listening connections. Default false."}},"required":["name"]},"annotations":{"title":"Find ports by process","readOnlyHint":true,"openWorldHint":false}}"#, r#"{"name":"doctor","title":"Diagnose port problems","description":"Run diagnostics: port conflicts between processes, services exposed on 0.0.0.0 that should be localhost-only, Docker/host port collisions, stale TIME_WAIT and CLOSE_WAIT pileups, and unusually high-memory listeners. Returns a JSON array of findings; an empty array means no problems.","inputSchema":{"type":"object","properties":{}},"annotations":{"title":"Diagnose port problems","readOnlyHint":true,"openWorldHint":false}}"#, r#"{"name":"diff_ports","title":"Compare ports against a baseline","description":"Report which ports opened, closed, or changed owner since a baseline. The first call records the baseline and returns no diff; call it again after the change you want to observe. Use this to answer 'what did starting/stopping this actually do to the ports?' without diffing two list_ports results by hand. A port whose PID changed is reported as replaced rather than as a close plus an open, so a restart is distinguishable from a shutdown. Pass reset=true to record a fresh baseline.","inputSchema":{"type":"object","properties":{"all":{"type":"boolean","description":"Track non-listening connections too. Default false. Changing this between calls compares different things."},"reset":{"type":"boolean","description":"Discard the stored baseline and record a new one from the current state. Default false."}}},"annotations":{"title":"Compare ports against a baseline","readOnlyHint":true,"openWorldHint":false}}"#, @@ -299,9 +299,14 @@ fn tool_inspect_port(args: &[(String, String)]) -> String { .map(|m| m.get(&info.port).map(|o| o.as_slice()).unwrap_or(&[][..])); let mut obj = port_info_json(info, owners); obj.pop(); // trailing '}' + // `children` in the base object is a count; this is the list. Distinct + // keys because they answer different questions and both are useful. + let (tree, truncated) = descendant_processes(info.pid); obj.push_str(&format!( - r#","cwd":"{}"}}"#, - json_escape(&get_process_cwd(info.pid)) + r#","cwd":"{}","child_processes":[{}],"child_processes_truncated":{}}}"#, + json_escape(&get_process_cwd(info.pid)), + tree.join(","), + truncated )); entries.push(obj); } @@ -335,6 +340,71 @@ fn tool_find_process(args: &[(String, String)]) -> String { tool_text(&ports_json_string(&matches, map.as_ref())) } +// ── Process tree (inspect_port) ────────────────────────────────────── + +/// Bounds on the descendant walk. +/// +/// This runs per `inspect_port` call, and a supervisor with hundreds of workers +/// would otherwise dominate the response. The node cap matters most on Windows, +/// which pays a process snapshot per node walked. +const TREE_MAX_DEPTH: usize = 3; +const TREE_MAX_NODES: usize = 64; + +/// Descendants of `root`, breadth-first. Returns `(entries, truncated)`. +/// +/// Flat rather than nested: every entry carries `ppid` and `depth`, which is +/// the same information a nested structure holds and is far simpler to emit and +/// to consume. Breadth-first so that when the cap truncates, what survives is +/// the shallowest part of the tree — the part the caller is most likely to +/// care about. +fn descendant_processes(root: u32) -> (Vec, bool) { + walk_descendants(root, get_child_processes) +} + +/// The walk itself, with the child lookup injected so the bounds and the cycle +/// guard can be tested without a real process tree. +fn walk_descendants( + root: u32, + children_of: impl Fn(u32) -> Vec<(u32, String)>, +) -> (Vec, bool) { + use std::collections::{HashSet, VecDeque}; + + let mut entries = Vec::new(); + let mut queue: VecDeque<(u32, usize)> = VecDeque::from([(root, 0usize)]); + // Guards against a reparenting race producing a cycle. It costs nothing and + // the alternative is an unbounded walk. + let mut seen: HashSet = HashSet::from([root]); + let mut truncated = false; + + while let Some((pid, depth)) = queue.pop_front() { + if depth >= TREE_MAX_DEPTH { + continue; + } + for (child, name) in children_of(pid) { + if !seen.insert(child) { + continue; + } + if entries.len() >= TREE_MAX_NODES { + truncated = true; + break; + } + entries.push(format!( + r#"{{"pid":{},"ppid":{},"process":"{}","depth":{}}}"#, + child, + pid, + json_escape(&name), + depth + 1 + )); + queue.push_back((child, depth + 1)); + } + if truncated { + break; + } + } + + (entries, truncated) +} + // ── Port baseline (diff_ports) ─────────────────────────────────────── /// What was listening when the baseline was taken. @@ -634,6 +704,74 @@ mod tests { assert!(r.contains("read-only"), "{}", r); } + // ── process tree ───────────────────────────────────────────────── + + /// A child lookup over a fixed `parent -> children` table. + fn tree_of<'a>(edges: &'a [(u32, &'a [u32])]) -> impl Fn(u32) -> Vec<(u32, String)> + 'a { + move |pid| { + edges + .iter() + .find(|(parent, _)| *parent == pid) + .map(|(_, kids)| { + kids.iter() + .map(|k| (*k, format!("proc{}", k))) + .collect::>() + }) + .unwrap_or_default() + } + } + + #[test] + fn descendants_are_reported_with_their_parent_and_depth() { + let (entries, truncated) = + walk_descendants(1, tree_of(&[(1, &[2, 3]), (2, &[4]), (4, &[5])])); + assert!(!truncated); + assert_eq!(entries.len(), 4, "{:#?}", entries); + assert!(entries[0].contains(r#""pid":2,"ppid":1"#), "{}", entries[0]); + assert!(entries[0].contains(r#""depth":1"#), "{}", entries[0]); + // Breadth-first: both depth-1 nodes precede the depth-2 node. + assert!(entries[1].contains(r#""depth":1"#), "{}", entries[1]); + assert!(entries[2].contains(r#""pid":4,"ppid":2"#), "{}", entries[2]); + assert!(entries[2].contains(r#""depth":2"#), "{}", entries[2]); + } + + #[test] + fn the_walk_stops_at_the_depth_limit() { + // A chain longer than TREE_MAX_DEPTH: only the first three levels below + // the root are reported. + let (entries, truncated) = walk_descendants( + 1, + tree_of(&[(1, &[2]), (2, &[3]), (3, &[4]), (4, &[5]), (5, &[6])]), + ); + assert!(!truncated, "a depth stop is not a node-cap truncation"); + assert_eq!(entries.len(), TREE_MAX_DEPTH, "{:#?}", entries); + assert!(entries.last().unwrap().contains(r#""pid":4"#)); + } + + #[test] + fn the_walk_reports_when_the_node_cap_truncates() { + // One parent with more children than the cap allows. + let many: Vec = (2..200).collect(); + let (entries, truncated) = walk_descendants(1, tree_of(&[(1, &many)])); + assert!(truncated, "hitting the cap must be reported, not hidden"); + assert_eq!(entries.len(), TREE_MAX_NODES); + } + + #[test] + fn a_parent_cycle_does_not_loop_forever() { + // Reparenting races can in principle produce this; the walk must end. + let (entries, _) = walk_descendants(1, tree_of(&[(1, &[2]), (2, &[1])])); + assert_eq!(entries.len(), 1, "{:#?}", entries); + assert!(entries[0].contains(r#""pid":2"#)); + } + + #[test] + fn a_process_with_no_children_yields_nothing() { + let (entries, truncated) = walk_descendants(1, tree_of(&[])); + assert!(entries.is_empty()); + assert!(!truncated); + } + // ── diff_ports ─────────────────────────────────────────────────── fn snap(entries: &[(u16, &str, u32, &str)]) -> PortMap { diff --git a/src/windows.rs b/src/windows.rs index 5f80b88..29e46e1 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -443,6 +443,43 @@ fn build_process_maps() -> (HashMap, HashMap) { (children_count, ppid_map) } +/// Direct children of `pid`, with their names. +/// +/// Name and PID come back together because the snapshot already carries both in +/// `szExeFile` — resolving names separately would mean opening a handle per +/// child, which fails for anything privileged. +pub fn get_child_processes(pid: u32) -> Vec<(u32, String)> { + let mut children = Vec::new(); + + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return children; + } + + let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() }; + entry.dwSize = std::mem::size_of::() as u32; + + if unsafe { Process32FirstW(snapshot, &mut entry) } != 0 { + loop { + if entry.th32ParentProcessID == pid && entry.th32ProcessID != pid { + let len = entry + .szExeFile + .iter() + .position(|&c| c == 0) + .unwrap_or(entry.szExeFile.len()); + let name = String::from_utf16_lossy(&entry.szExeFile[..len]); + children.push((entry.th32ProcessID, name)); + } + if unsafe { Process32NextW(snapshot, &mut entry) } == 0 { + break; + } + } + } + + unsafe { CloseHandle(snapshot) }; + children +} + // ── Main entry point ───────────────────────────────────────────────── pub fn get_port_infos(filter_listening: bool) -> Vec {