Skip to main content

mcp_servers/coding/tools/bash/
mod.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::path::Path;
4use std::time::Duration;
5use tokio::io::{AsyncBufReadExt, BufReader};
6use tokio::process::Command;
7use tokio::sync::mpsc;
8use tokio::task::JoinHandle;
9use uuid::Uuid;
10
11use crate::coding::error::BashError;
12use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta, truncate};
13
14#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
15#[serde(rename_all = "camelCase")]
16pub struct BashInput {
17    /// The command to execute
18    pub command: String,
19    /// Optional timeout in milliseconds (max 600000)
20    pub timeout: Option<u64>,
21    /// Clear, concise description of what this command does in 5-10 words
22    pub description: Option<String>,
23    /// Set to true to run this command in the background
24    #[serde(alias = "run_in_background")]
25    pub run_in_background: Option<bool>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "camelCase")]
30pub struct BashOutput {
31    /// Combined stdout and stderr output
32    pub output: String,
33    /// Exit code of the command
34    pub exit_code: i32,
35    /// Whether the command was killed due to timeout
36    pub killed: Option<bool>,
37    /// Shell ID for background processes
38    pub shell_id: Option<String>,
39    /// Display metadata for human-friendly rendering
40    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
41    #[schemars(skip)]
42    pub meta: Option<ToolResultMeta>,
43}
44
45#[derive(Debug)]
46pub struct BackgroundProcessHandle {
47    pub shell_id: String,
48    pub output_rx: mpsc::UnboundedReceiver<String>,
49    pub task_handle: JoinHandle<(i32, bool)>,
50}
51
52#[derive(Debug)]
53pub enum BashResult {
54    Completed(BashOutput),
55    Background(BackgroundProcessHandle),
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
59#[serde(rename_all = "camelCase")]
60pub struct ReadBackgroundBashInput {
61    /// The ID of the background shell to retrieve output from
62    #[serde(alias = "bash_id")]
63    pub bash_id: String,
64    /// Optional regex to filter output lines
65    pub filter: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "camelCase")]
70pub struct ReadBackgroundBashOutput {
71    /// New output since last check
72    pub output: String,
73    /// Current shell status
74    pub status: String, // "running" | "completed" | "failed"
75    /// Exit code (when completed)
76    pub exit_code: Option<i32>,
77    /// Display metadata for human-friendly rendering
78    #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
79    #[schemars(skip)]
80    pub meta: Option<ToolResultMeta>,
81}
82
83pub async fn read_background_bash(
84    handle: BackgroundProcessHandle,
85    filter: Option<String>,
86) -> Result<(ReadBackgroundBashOutput, Option<BackgroundProcessHandle>), BashError> {
87    let BackgroundProcessHandle { shell_id, mut output_rx, task_handle } = handle;
88
89    // Collect all available output
90    let mut output = String::new();
91    let filter_regex = if let Some(pattern) = filter {
92        Some(regex::Regex::new(&pattern).map_err(|e| BashError::InvalidRegex(e.to_string()))?)
93    } else {
94        None
95    };
96
97    while let Ok(line) = output_rx.try_recv() {
98        if let Some(ref regex) = filter_regex {
99            if regex.is_match(&line) {
100                output.push_str(&line);
101            }
102        } else {
103            output.push_str(&line);
104        }
105    }
106
107    if task_handle.is_finished() {
108        let (exit_code, killed) = task_handle.await.map_err(|e| BashError::JoinFailed(e.to_string()))?;
109
110        let status = if killed { "failed".to_string() } else { "completed".to_string() };
111
112        let display_meta = ToolDisplayMeta::new("Run command", format!("<background> (exit {exit_code})"));
113
114        Ok((
115            ReadBackgroundBashOutput { output, status, exit_code: Some(exit_code), meta: Some(display_meta.into()) },
116            None,
117        ))
118    } else {
119        let display_meta = ToolDisplayMeta::new("Run command", "<background> (running)");
120
121        Ok((
122            ReadBackgroundBashOutput {
123                output,
124                status: "running".to_string(),
125                exit_code: None,
126                meta: Some(display_meta.into()),
127            },
128            Some(BackgroundProcessHandle { shell_id, output_rx, task_handle }),
129        ))
130    }
131}
132
133async fn run_command_with_timeout(
134    command: String,
135    timeout: Option<Duration>,
136    output_tx: mpsc::UnboundedSender<String>,
137    cwd: Option<&std::path::Path>,
138) -> (i32, bool) {
139    let mut cmd = Command::new("bash");
140    cmd.arg("-c").arg(&command);
141    if let Some(dir) = cwd {
142        cmd.current_dir(dir);
143    }
144    cmd.stdout(std::process::Stdio::piped());
145    cmd.stderr(std::process::Stdio::piped());
146
147    let tx_clone = output_tx.clone();
148    let run_command = async move {
149        let mut child = cmd.spawn().map_err(|e| format!("Failed to spawn: {e}"))?;
150
151        if let Some(stdout) = child.stdout.take() {
152            let tx = tx_clone.clone();
153            tokio::spawn(async move {
154                let reader = BufReader::new(stdout);
155                let mut lines = reader.lines();
156                while let Ok(Some(line)) = lines.next_line().await {
157                    let _ = tx.send(line + "\n");
158                }
159            });
160        }
161
162        // Stream stderr
163        if let Some(stderr) = child.stderr.take() {
164            let tx = tx_clone.clone();
165            tokio::spawn(async move {
166                let reader = BufReader::new(stderr);
167                let mut lines = reader.lines();
168                while let Ok(Some(line)) = lines.next_line().await {
169                    let _ = tx.send(line + "\n");
170                }
171            });
172        }
173
174        let status = child.wait().await.map_err(|e| format!("Wait failed: {e}"))?;
175        Ok::<_, String>((status.code().unwrap_or(-1), false))
176    };
177
178    if let Some(timeout_duration) = timeout {
179        match tokio::time::timeout(timeout_duration, run_command).await {
180            Ok(Ok(result)) => result,
181            Ok(Err(_)) => (-1, false),
182            Err(_) => {
183                let _ = output_tx.send("Command timed out\n".to_string());
184                (-1, true)
185            }
186        }
187    } else {
188        run_command.await.unwrap_or((-1, false))
189    }
190}
191
192pub async fn execute_command(args: BashInput) -> Result<BashResult, BashError> {
193    execute_command_in_dir(args, None).await
194}
195
196pub async fn execute_command_in_dir(args: BashInput, cwd: Option<&std::path::Path>) -> Result<BashResult, BashError> {
197    if args.command.trim() == "rm" {
198        return Err(BashError::Forbidden("No you can't fucking delete files".to_string()));
199    }
200
201    // Validate timeout is within bounds
202    if let Some(timeout_ms) = args.timeout
203        && timeout_ms > 600_000
204    {
205        return Err(BashError::TimeoutTooLarge);
206    }
207
208    let run_in_background = args.run_in_background.unwrap_or(false);
209    let timeout_duration = args.timeout.map(Duration::from_millis);
210
211    if run_in_background {
212        let shell_id = Uuid::new_v4().to_string();
213        let command = args.command.clone();
214        let cwd = cwd.map(Path::to_path_buf);
215
216        let (output_tx, output_rx) = mpsc::unbounded_channel();
217
218        let task_handle = tokio::spawn(async move {
219            run_command_with_timeout(command, timeout_duration, output_tx, cwd.as_deref()).await
220        });
221
222        Ok(BashResult::Background(BackgroundProcessHandle { shell_id, output_rx, task_handle }))
223    } else {
224        // Run synchronously with default timeout of 120000ms (2 minutes)
225        let timeout = timeout_duration.or(Some(Duration::from_mins(2)));
226        let command = args.command.clone();
227
228        // Collect output in-memory for synchronous case
229        let mut cmd = Command::new("bash");
230        cmd.arg("-c").arg(&command);
231        if let Some(dir) = cwd {
232            cmd.current_dir(dir);
233        }
234
235        let result = if let Some(timeout_duration) = timeout {
236            tokio::time::timeout(timeout_duration, cmd.output()).await
237        } else {
238            Ok(cmd.output().await)
239        };
240
241        match result {
242            Ok(Ok(output)) => {
243                let stdout = String::from_utf8_lossy(&output.stdout).to_string();
244                let stderr = String::from_utf8_lossy(&output.stderr).to_string();
245                let exit_code = output.status.code().unwrap_or(-1);
246
247                let combined_output = if stderr.is_empty() {
248                    stdout
249                } else if stdout.is_empty() {
250                    stderr
251                } else {
252                    format!("{stdout}{stderr}")
253                };
254
255                let display_meta =
256                    ToolDisplayMeta::new("Run command", format!("{} (exit {exit_code})", truncate(&args.command, 40)));
257
258                Ok(BashResult::Completed(BashOutput {
259                    output: combined_output,
260                    exit_code,
261                    killed: Some(false),
262                    shell_id: None,
263                    meta: Some(display_meta.into()),
264                }))
265            }
266            Ok(Err(e)) => Err(BashError::SpawnFailed { command: args.command, reason: e.to_string() }),
267            Err(_) => {
268                // Timeout occurred
269                let timeout_ms = timeout.map_or(120_000, |d| d.as_millis());
270
271                let display_meta = ToolDisplayMeta::new(
272                    "Run command",
273                    format!("{} (exit -1, timed out)", truncate(&args.command, 40)),
274                );
275
276                Ok(BashResult::Completed(BashOutput {
277                    output: format!("Command timed out after {timeout_ms}ms"),
278                    exit_code: -1,
279                    killed: Some(true),
280                    shell_id: None,
281                    meta: Some(display_meta.into()),
282                }))
283            }
284        }
285    }
286}