mcp_servers/coding/tools/bash/
mod.rs1use 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 pub command: String,
19 pub timeout: Option<u64>,
21 pub description: Option<String>,
23 #[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 pub output: String,
33 pub exit_code: i32,
35 pub killed: Option<bool>,
37 pub shell_id: Option<String>,
39 #[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 #[serde(alias = "bash_id")]
63 pub bash_id: String,
64 pub filter: Option<String>,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
69#[serde(rename_all = "camelCase")]
70pub struct ReadBackgroundBashOutput {
71 pub output: String,
73 pub status: String, pub exit_code: Option<i32>,
77 #[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 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 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 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 let timeout = timeout_duration.or(Some(Duration::from_mins(2)));
226 let command = args.command.clone();
227
228 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 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}