use super::super::{McpFunction, McpToolCall, McpToolResult};
use anyhow::{anyhow, Result};
use serde_json::{json, Value};
pub fn get_shell_function() -> McpFunction {
McpFunction {
name: "shell".to_string(),
description: "Execute a command in the shell.
This will return the output and error concatenated into a single string, as
you would see from running on the command line. There will also be an indication
of if the command succeeded or failed.
Parameters:
- `command`: The shell command to execute (required)
- `background`: Run command in background and return PID instead of waiting for completion (default: false)
**Working Directory:**
All commands execute from the current working directory.
NO `cd` command is required when you working with current project files.
REMEMBER that each command you run has NO knowledge of previous runs, other context, or variables that were set BEFORE in another command.
**Output Truncation:**
Output size is controlled by global mcp_response_tokens_threshold setting.
Avoid commands that produce large outputs (like `cat large_file` or `find /` without filters).
Use more specific commands to reduce output size if responses are truncated.
**Background Execution:**
When `background` is true, the command runs in the background and returns immediately with the process PID.
Background processes continue running until explicitly killed or the main application exits.
Use the returned PID with `kill <pid>` command to terminate background processes.
NO need to append `&` to your command when using `background: true` - this is handled automatically.
**Important**: Each shell command runs in its own process. Things like directory changes or
sourcing files do not persist between tool calls. So you may need to repeat them each time by
stringing together commands, e.g. `cd example && ls` or `source env/bin/activate && pip install numpy`
**Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions
may show ignored or hidden files. For example *do not* use `find` or `ls -r`
- List files by name: `rg --files | rg <filename>`
- List files that contain a regex: `rg '<regex>' -l`
Examples:
- Foreground: `{\"command\": \"ls -la\"}`
- Background: `{\"command\": \"python -m http.server 8000\", \"background\": true}`
- Kill background: `{\"command\": \"kill 12345\"}` (where 12345 is the returned PID)
".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"],
"text_editor",
"⚠️ Prefer `text_editor` 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 `list_files` with content= for text search (.gitignore-aware). Use shell grep only for unsupported raw flags.",
),
(
&["find", "ls"],
"list_files",
"⚠️ Prefer `list_files` 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)) {
if crate::mcp::tool_map::get_server_for_tool(tool).is_some() {
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 mut cmd = if cfg!(target_os = "windows") {
let mut cmd = TokioCommand::new("cmd");
cmd.args(["/C", &command]);
cmd
} else {
let mut cmd = TokioCommand::new("sh");
cmd.args(["-c", &command]);
cmd
};
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 result = child.wait_with_output().await;
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}"),
)),
}
}