1use crate::{RustBash, RustBashBuilder};
8use serde_json::{Value, json};
9use std::collections::HashMap;
10use std::io::{self, BufRead, Write};
11
12const MAX_OUTPUT_LEN: usize = 100_000;
13
14pub fn run_mcp_server() -> Result<(), Box<dyn std::error::Error>> {
17 let builder = RustBashBuilder::new()
18 .env(HashMap::from([
19 ("HOME".to_string(), "/home".to_string()),
20 ("USER".to_string(), "user".to_string()),
21 ("PWD".to_string(), "/".to_string()),
22 ]))
23 .cwd("/");
24 let mut shell = builder.build()?;
25
26 let stdin = io::stdin();
27 let stdout = io::stdout();
28 let mut stdout = stdout.lock();
29
30 for line in stdin.lock().lines() {
31 let line = line?;
32 let trimmed = line.trim();
33 if trimmed.is_empty() {
34 continue;
35 }
36
37 let request: Value = match serde_json::from_str(trimmed) {
38 Ok(v) => v,
39 Err(e) => {
40 let error_response = json!({
41 "jsonrpc": "2.0",
42 "id": null,
43 "error": {
44 "code": -32700,
45 "message": format!("Parse error: {e}")
46 }
47 });
48 write_response(&mut stdout, &error_response)?;
49 continue;
50 }
51 };
52
53 if let Some(response) = handle_message(&mut shell, &request) {
54 write_response(&mut stdout, &response)?;
55 }
56 }
58
59 Ok(())
60}
61
62fn write_response(stdout: &mut impl Write, response: &Value) -> io::Result<()> {
63 let serialized = serde_json::to_string(response).expect("JSON serialization should not fail");
64 writeln!(stdout, "{serialized}")?;
65 stdout.flush()
66}
67
68fn handle_message(shell: &mut RustBash, request: &Value) -> Option<Value> {
69 let id = request.get("id");
70
71 if id.is_none() || id == Some(&Value::Null) {
73 return None;
74 }
75
76 let id = id.unwrap().clone();
77
78 let method = match request.get("method").and_then(|v| v.as_str()) {
79 Some(m) => m,
80 None => {
81 return Some(json!({
82 "jsonrpc": "2.0",
83 "id": id,
84 "error": {
85 "code": -32600,
86 "message": "Invalid Request: missing or non-string method"
87 }
88 }));
89 }
90 };
91
92 let result = match method {
93 "initialize" => handle_initialize(),
94 "tools/list" => handle_tools_list(),
95 "tools/call" => handle_tools_call(shell, request.get("params")),
96 _ => {
97 return Some(json!({
98 "jsonrpc": "2.0",
99 "id": id,
100 "error": {
101 "code": -32601,
102 "message": format!("Method not found: {method}")
103 }
104 }));
105 }
106 };
107
108 match result {
109 Ok(value) => Some(json!({
110 "jsonrpc": "2.0",
111 "id": id,
112 "result": value
113 })),
114 Err(e) => Some(json!({
115 "jsonrpc": "2.0",
116 "id": id,
117 "error": {
118 "code": -32603,
119 "message": e
120 }
121 })),
122 }
123}
124
125fn handle_initialize() -> Result<Value, String> {
126 Ok(json!({
127 "protocolVersion": "2024-11-05",
128 "capabilities": {
129 "tools": {}
130 },
131 "serverInfo": {
132 "name": "rust-bash",
133 "version": env!("CARGO_PKG_VERSION")
134 }
135 }))
136}
137
138fn handle_tools_list() -> Result<Value, String> {
139 Ok(json!({
140 "tools": [
141 {
142 "name": "bash",
143 "description": "Execute bash commands in a sandboxed environment with an in-memory virtual filesystem. Supports standard Unix utilities including grep, sed, awk, jq, cat, echo, and more. All file operations are isolated within the sandbox. State persists between calls.",
144 "inputSchema": {
145 "type": "object",
146 "properties": {
147 "command": {
148 "type": "string",
149 "description": "The bash command to execute"
150 }
151 },
152 "required": ["command"]
153 }
154 },
155 {
156 "name": "write_file",
157 "description": "Write content to a file in the sandboxed virtual filesystem. Creates parent directories automatically.",
158 "inputSchema": {
159 "type": "object",
160 "properties": {
161 "path": {
162 "type": "string",
163 "description": "The absolute path to write to"
164 },
165 "content": {
166 "type": "string",
167 "description": "The content to write"
168 }
169 },
170 "required": ["path", "content"]
171 }
172 },
173 {
174 "name": "read_file",
175 "description": "Read the contents of a file from the sandboxed virtual filesystem.",
176 "inputSchema": {
177 "type": "object",
178 "properties": {
179 "path": {
180 "type": "string",
181 "description": "The absolute path to read"
182 }
183 },
184 "required": ["path"]
185 }
186 },
187 {
188 "name": "list_directory",
189 "description": "List the contents of a directory in the sandboxed virtual filesystem.",
190 "inputSchema": {
191 "type": "object",
192 "properties": {
193 "path": {
194 "type": "string",
195 "description": "The absolute path of the directory to list"
196 }
197 },
198 "required": ["path"]
199 }
200 }
201 ]
202 }))
203}
204
205fn truncate_output(s: &str) -> String {
206 if s.len() <= MAX_OUTPUT_LEN {
207 return s.to_string();
208 }
209 let mut end = MAX_OUTPUT_LEN;
211 while end > 0 && !s.is_char_boundary(end) {
212 end -= 1;
213 }
214 format!("{}\n... (truncated, {} total chars)", &s[..end], s.len())
215}
216
217fn handle_tools_call(shell: &mut RustBash, params: Option<&Value>) -> Result<Value, String> {
218 let params = params.ok_or("Missing params")?;
219 let tool_name = params
220 .get("name")
221 .and_then(|v| v.as_str())
222 .ok_or("Missing tool name")?;
223 let empty_obj = Value::Object(Default::default());
224 let arguments = params.get("arguments").unwrap_or(&empty_obj);
225
226 match tool_name {
227 "bash" => {
228 let command = arguments
229 .get("command")
230 .and_then(|v| v.as_str())
231 .ok_or("Missing 'command' argument")?;
232
233 match shell.exec(command) {
234 Ok(result) => {
235 let stdout = truncate_output(&result.stdout);
236 let stderr = truncate_output(&result.stderr);
237 let text = format!(
238 "stdout:\n{stdout}\nstderr:\n{stderr}\nexit_code: {}",
239 result.exit_code
240 );
241 let is_error = result.exit_code != 0;
242 Ok(json!({
243 "content": [{ "type": "text", "text": text }],
244 "isError": is_error
245 }))
246 }
247 Err(e) => Ok(json!({
248 "content": [{ "type": "text", "text": format!("Error: {e}") }],
249 "isError": true
250 })),
251 }
252 }
253 "write_file" => {
254 let path = arguments
255 .get("path")
256 .and_then(|v| v.as_str())
257 .ok_or("Missing 'path' argument")?;
258 let content = arguments
259 .get("content")
260 .and_then(|v| v.as_str())
261 .ok_or("Missing 'content' argument")?;
262
263 match shell.write_file(path, content.as_bytes()) {
264 Ok(()) => Ok(json!({
265 "content": [{ "type": "text", "text": format!("Written {path}") }]
266 })),
267 Err(e) => Ok(json!({
268 "content": [{ "type": "text", "text": format!("Error: {e}") }],
269 "isError": true
270 })),
271 }
272 }
273 "read_file" => {
274 let path = arguments
275 .get("path")
276 .and_then(|v| v.as_str())
277 .ok_or("Missing 'path' argument")?;
278
279 match shell.read_file(path) {
280 Ok(bytes) => {
281 let text = String::from_utf8_lossy(&bytes).into_owned();
282 Ok(json!({
283 "content": [{ "type": "text", "text": text }]
284 }))
285 }
286 Err(e) => Ok(json!({
287 "content": [{ "type": "text", "text": format!("Error: {e}") }],
288 "isError": true
289 })),
290 }
291 }
292 "list_directory" => {
293 let path = arguments
294 .get("path")
295 .and_then(|v| v.as_str())
296 .ok_or("Missing 'path' argument")?;
297
298 match shell.readdir(path) {
299 Ok(entries) => {
300 let listing: Vec<String> = entries
301 .iter()
302 .map(|e| {
303 let suffix = match e.node_type {
304 crate::vfs::NodeType::Directory => "/",
305 _ => "",
306 };
307 format!("{}{suffix}", e.name)
308 })
309 .collect();
310 let text = listing.join("\n");
311 Ok(json!({
312 "content": [{ "type": "text", "text": text }]
313 }))
314 }
315 Err(e) => Ok(json!({
316 "content": [{ "type": "text", "text": format!("Error: {e}") }],
317 "isError": true
318 })),
319 }
320 }
321 _ => Err(format!("Unknown tool: {tool_name}")),
322 }
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 #[test]
330 fn test_initialize_response() {
331 let result = handle_initialize().unwrap();
332 assert_eq!(result["protocolVersion"], "2024-11-05");
333 assert!(result["serverInfo"]["name"].as_str().unwrap() == "rust-bash");
334 assert!(result["capabilities"]["tools"].is_object());
335 }
336
337 #[test]
338 fn test_tools_list_returns_four_tools() {
339 let result = handle_tools_list().unwrap();
340 let tools = result["tools"].as_array().unwrap();
341 assert_eq!(tools.len(), 4);
342
343 let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
344 assert!(names.contains(&"bash"));
345 assert!(names.contains(&"write_file"));
346 assert!(names.contains(&"read_file"));
347 assert!(names.contains(&"list_directory"));
348 }
349
350 #[test]
351 fn test_tools_list_schemas_have_required_fields() {
352 let result = handle_tools_list().unwrap();
353 let tools = result["tools"].as_array().unwrap();
354 for tool in tools {
355 assert!(tool["name"].is_string());
356 assert!(tool["description"].is_string());
357 assert!(tool["inputSchema"]["type"].as_str().unwrap() == "object");
358 assert!(tool["inputSchema"]["properties"].is_object());
359 assert!(tool["inputSchema"]["required"].is_array());
360 }
361 }
362
363 fn create_test_shell() -> RustBash {
364 RustBashBuilder::new()
365 .cwd("/")
366 .env(HashMap::from([
367 ("HOME".to_string(), "/home".to_string()),
368 ("USER".to_string(), "user".to_string()),
369 ]))
370 .build()
371 .unwrap()
372 }
373
374 #[test]
375 fn test_bash_tool_call() {
376 let mut shell = create_test_shell();
377 let params = json!({
378 "name": "bash",
379 "arguments": { "command": "echo hello" }
380 });
381 let result = handle_tools_call(&mut shell, Some(¶ms)).unwrap();
382 let text = result["content"][0]["text"].as_str().unwrap();
383 assert!(text.contains("hello"));
384 assert!(text.contains("exit_code: 0"));
385 }
386
387 #[test]
388 fn test_write_and_read_file_tool() {
389 let mut shell = create_test_shell();
390
391 let write_params = json!({
393 "name": "write_file",
394 "arguments": { "path": "/test.txt", "content": "test content" }
395 });
396 let result = handle_tools_call(&mut shell, Some(&write_params)).unwrap();
397 let text = result["content"][0]["text"].as_str().unwrap();
398 assert!(text.contains("Written"));
399
400 let read_params = json!({
402 "name": "read_file",
403 "arguments": { "path": "/test.txt" }
404 });
405 let result = handle_tools_call(&mut shell, Some(&read_params)).unwrap();
406 let text = result["content"][0]["text"].as_str().unwrap();
407 assert_eq!(text, "test content");
408 }
409
410 #[test]
411 fn test_list_directory_tool() {
412 let mut shell = create_test_shell();
413
414 shell.write_file("/mydir/a.txt", b"a").unwrap();
416 shell.write_file("/mydir/b.txt", b"b").unwrap();
417
418 let params = json!({
419 "name": "list_directory",
420 "arguments": { "path": "/mydir" }
421 });
422 let result = handle_tools_call(&mut shell, Some(¶ms)).unwrap();
423 let text = result["content"][0]["text"].as_str().unwrap();
424 assert!(text.contains("a.txt"));
425 assert!(text.contains("b.txt"));
426 }
427
428 #[test]
429 fn test_read_nonexistent_file_returns_error() {
430 let mut shell = create_test_shell();
431 let params = json!({
432 "name": "read_file",
433 "arguments": { "path": "/nonexistent.txt" }
434 });
435 let result = handle_tools_call(&mut shell, Some(¶ms)).unwrap();
436 assert_eq!(result["isError"], true);
437 }
438
439 #[test]
440 fn test_unknown_tool_returns_error() {
441 let mut shell = create_test_shell();
442 let params = json!({
443 "name": "unknown_tool",
444 "arguments": {}
445 });
446 let result = handle_tools_call(&mut shell, Some(¶ms));
447 assert!(result.is_err());
448 }
449
450 #[test]
451 fn test_handle_message_initialize() {
452 let mut shell = create_test_shell();
453 let request = json!({
454 "jsonrpc": "2.0",
455 "id": 1,
456 "method": "initialize",
457 "params": {}
458 });
459 let response = handle_message(&mut shell, &request).unwrap();
460 assert_eq!(response["id"], 1);
461 assert!(response["result"]["serverInfo"].is_object());
462 }
463
464 #[test]
465 fn test_handle_message_notification_returns_none() {
466 let mut shell = create_test_shell();
467 let request = json!({
468 "jsonrpc": "2.0",
469 "method": "notifications/initialized"
470 });
471 let response = handle_message(&mut shell, &request);
472 assert!(response.is_none());
473 }
474
475 #[test]
476 fn test_handle_message_unknown_method() {
477 let mut shell = create_test_shell();
478 let request = json!({
479 "jsonrpc": "2.0",
480 "id": 5,
481 "method": "unknown/method",
482 "params": {}
483 });
484 let response = handle_message(&mut shell, &request).unwrap();
485 assert!(response["error"]["code"].as_i64().unwrap() == -32601);
486 }
487
488 #[test]
489 fn test_bash_error_command_returns_is_error() {
490 let mut shell = create_test_shell();
491 let params = json!({
492 "name": "bash",
493 "arguments": { "command": "cat /nonexistent_file_404" }
494 });
495 let result = handle_tools_call(&mut shell, Some(¶ms)).unwrap();
496 assert_eq!(result["isError"], true);
497 }
498
499 #[test]
500 fn test_stateful_session() {
501 let mut shell = create_test_shell();
502
503 let params1 = json!({
505 "name": "bash",
506 "arguments": { "command": "export MY_VAR=hello123" }
507 });
508 handle_tools_call(&mut shell, Some(¶ms1)).unwrap();
509
510 let params2 = json!({
512 "name": "bash",
513 "arguments": { "command": "echo $MY_VAR" }
514 });
515 let result = handle_tools_call(&mut shell, Some(¶ms2)).unwrap();
516 let text = result["content"][0]["text"].as_str().unwrap();
517 assert!(text.contains("hello123"));
518 }
519
520 #[test]
521 fn test_handle_message_missing_method_with_id() {
522 let mut shell = create_test_shell();
523 let request = json!({
524 "jsonrpc": "2.0",
525 "id": 7
526 });
527 let response = handle_message(&mut shell, &request).unwrap();
528 assert_eq!(response["error"]["code"], -32600);
529 }
530
531 #[test]
532 fn test_handle_message_non_string_method_with_id() {
533 let mut shell = create_test_shell();
534 let request = json!({
535 "jsonrpc": "2.0",
536 "id": 8,
537 "method": 42
538 });
539 let response = handle_message(&mut shell, &request).unwrap();
540 assert_eq!(response["error"]["code"], -32600);
541 }
542
543 #[test]
544 fn test_truncate_output_short() {
545 let s = "hello world";
546 assert_eq!(truncate_output(s), s);
547 }
548
549 #[test]
550 fn test_truncate_output_long() {
551 let s = "x".repeat(MAX_OUTPUT_LEN + 100);
552 let result = truncate_output(&s);
553 assert!(result.len() < s.len());
554 assert!(result.contains("truncated"));
555 }
556}