Skip to main content

rust_bash/
mcp.rs

1//! MCP (Model Context Protocol) server over stdio.
2//!
3//! Implements the minimal MCP subset: `initialize`, `tools/list`, `tools/call`,
4//! and `notifications/initialized`. Communicates via newline-delimited JSON-RPC
5//! over stdin/stdout.
6
7use 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
14/// Run the MCP server loop, reading JSON-RPC messages from stdin and writing
15/// responses to stdout. Each line is one JSON-RPC message.
16pub 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        // Notifications (no "id") that we don't respond to just get dropped
57    }
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    // Notifications have no "id" — we don't respond to them
72    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    // Find a valid UTF-8 char boundary at or before MAX_OUTPUT_LEN
210    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(&params)).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        // Write a file
392        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        // Read it back
401        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        // Create a file first
415        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(&params)).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(&params)).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(&params));
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(&params)).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        // Set a variable
504        let params1 = json!({
505            "name": "bash",
506            "arguments": { "command": "export MY_VAR=hello123" }
507        });
508        handle_tools_call(&mut shell, Some(&params1)).unwrap();
509
510        // Read it back
511        let params2 = json!({
512            "name": "bash",
513            "arguments": { "command": "echo $MY_VAR" }
514        });
515        let result = handle_tools_call(&mut shell, Some(&params2)).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}