use super::super::{get_thread_working_directory, McpFunction, McpToolCall, McpToolResult};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
use std::collections::HashSet;
use std::sync::Mutex;
static SHELL_CHILDREN: Mutex<Option<HashSet<u32>>> = Mutex::new(None);
fn register_child(pid: u32) {
SHELL_CHILDREN
.lock()
.unwrap()
.get_or_insert_with(HashSet::new)
.insert(pid);
}
fn unregister_child(pid: u32) {
if let Some(set) = SHELL_CHILDREN.lock().unwrap().as_mut() {
set.remove(&pid);
}
}
#[cfg(unix)]
pub fn kill_all_shell_children() {
let pids: Vec<u32> = SHELL_CHILDREN
.lock()
.unwrap()
.as_mut()
.map(|set| set.drain().collect())
.unwrap_or_default();
for pid in pids {
let pgid = pid as libc::pid_t;
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
}
#[cfg(not(unix))]
pub fn kill_all_shell_children() {
if let Some(set) = SHELL_CHILDREN.lock().unwrap().as_mut() {
set.clear();
}
}
pub fn get_shell_function() -> McpFunction {
McpFunction {
name: "shell".to_string(),
description: "Execute a command in the shell. Returns stdout+stderr combined, with success/failure indication.
Each command runs in its own process — state (cd, exports) does not persist. Chain with `&&`: `cd foo && cargo build`.
Background: set `background: true` to get a PID immediately; kill with `kill <pid>`.
Examples:
- `{\"command\": \"cargo test\"}`
- `{\"command\": \"python -m http.server 8000\", \"background\": true}`
- `{\"command\": \"kill 12345\"}`
".to_string(),
parameters: json!({
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute (runs from current working directory)"
},
"background": {
"type": "boolean",
"default": false,
"description": "Run command in background and return PID instead of waiting for completion (no need to append '&')"
}
},
"required": ["command"]
}),
}
}
static SHELL_MISUSE_HINTS: &[(&[&str], &str, &str)] = &[
(
&["cat", "head", "tail", "less", "more"],
"view",
"⚠️ Prefer `view` for reading files (line-numbered, supports ranges). Use shell only when piping output.",
),
(
&["grep", "egrep", "fgrep", "rg"],
"ast_grep",
"⚠️ Prefer `ast_grep` for code search or `view` with content= for text search (.gitignore-aware). Use shell grep only for unsupported raw flags.",
),
(
&["find", "ls"],
"view",
"⚠️ Prefer `view` for directory listing (.gitignore-aware, pattern/content filtering). Use shell only for system paths outside the project.",
),
(
&["sed", "awk"],
"text_editor",
"⚠️ Prefer `text_editor` str_replace/line_replace for file edits (atomic, tracked). Use sed/awk only for stream transforms in pipelines.",
),
];
fn detect_shell_misuse(command: &str) -> Option<&'static str> {
let cmd = command.trim();
let is_prog = |prog: &str| -> bool {
cmd == prog || cmd.starts_with(&format!("{prog} ")) || cmd.starts_with(&format!("{prog}\t"))
};
for (progs, _tool, hint) in SHELL_MISUSE_HINTS {
if progs.iter().any(|p| is_prog(p)) {
return Some(hint);
}
}
None
}
pub async fn execute_shell_command(call: &McpToolCall) -> Result<McpToolResult> {
use tokio::process::Command as TokioCommand;
let command = match call.parameters.get("command") {
Some(Value::String(cmd)) => {
if cmd.trim().is_empty() {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Command parameter cannot be empty".to_string(),
));
}
cmd.clone()
}
Some(_) => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Command parameter must be a string".to_string(),
));
}
None => {
return Ok(McpToolResult::error(
call.tool_name.clone(),
call.tool_id.clone(),
"Missing required 'command' parameter".to_string(),
));
}
};
let background = call
.parameters
.get("background")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let working_dir = get_thread_working_directory();
let mut cmd = if cfg!(target_os = "windows") {
let mut cmd = TokioCommand::new("cmd");
cmd.args(["/C", &command]);
cmd.current_dir(&working_dir);
cmd
} else {
let mut cmd = TokioCommand::new("sh");
cmd.args(["-c", &command]);
cmd.current_dir(&working_dir);
cmd
};
#[cfg(unix)]
{
cmd.process_group(0);
}
if background {
cmd.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.stdin(std::process::Stdio::null())
.kill_on_drop(false); } else {
cmd.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.stdin(std::process::Stdio::null())
.kill_on_drop(true); }
let child = cmd
.spawn()
.map_err(|e| anyhow!("Failed to spawn command: {}", e))?;
if background {
let pid = child
.id()
.ok_or_else(|| anyhow!("Failed to get process ID"))?;
std::mem::forget(child);
return Ok(McpToolResult {
tool_name: "shell".to_string(),
tool_id: call.tool_id.clone(),
result: json!({
"success": true,
"background": true,
"pid": pid,
"command": command,
"message": format!("Command started in background with PID {pid}"),
"note": format!("Use 'kill {pid}' to terminate this background process if needed")
}),
});
}
let child_pid = child.id();
if let Some(pid) = child_pid {
register_child(pid);
}
let result = child.wait_with_output().await;
if let Some(pid) = child_pid {
unregister_child(pid);
}
match result.map_err(|e| anyhow!("Command execution failed: {}", e)) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let combined = if stderr.is_empty() {
stdout
} else if stdout.is_empty() {
stderr
} else {
format!("{stdout}\n\nError: {stderr}")
};
let final_output = combined;
let status_code = output.status.code().unwrap_or(-1);
let success = output.status.success();
if let Some(hint) = detect_shell_misuse(&command) {
crate::mcp::hint_accumulator::push_hint(hint);
}
if success {
Ok(McpToolResult::success(
"shell".to_string(),
call.tool_id.clone(),
final_output,
))
} else {
Ok(McpToolResult::error(
"shell".to_string(),
call.tool_id.clone(),
format!(
"Command failed with exit code {status_code}\nCommand: {command}\n\nOutput:\n{final_output}"
),
))
}
}
Err(e) => Ok(McpToolResult::error(
"shell".to_string(),
call.tool_id.clone(),
format!("Error: {e}"),
)),
}
}