1use std::io::{BufRead, Write};
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6
7use crate::ssh_config::model::{SshConfigFile, is_host_pattern};
8
9#[derive(Debug, Deserialize)]
11pub struct JsonRpcRequest {
12 #[allow(dead_code)]
13 pub jsonrpc: String,
14 #[serde(default)]
15 pub id: Option<Value>,
16 pub method: String,
17 #[serde(default)]
18 pub params: Option<Value>,
19}
20
21#[derive(Debug, Serialize)]
23pub struct JsonRpcResponse {
24 pub jsonrpc: String,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub id: Option<Value>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub result: Option<Value>,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 pub error: Option<JsonRpcError>,
31}
32
33#[derive(Debug, Serialize)]
35pub struct JsonRpcError {
36 pub code: i64,
37 pub message: String,
38}
39
40impl JsonRpcResponse {
41 fn success(id: Option<Value>, result: Value) -> Self {
42 Self {
43 jsonrpc: "2.0".to_string(),
44 id,
45 result: Some(result),
46 error: None,
47 }
48 }
49
50 fn error(id: Option<Value>, code: i64, message: String) -> Self {
51 Self {
52 jsonrpc: "2.0".to_string(),
53 id,
54 result: None,
55 error: Some(JsonRpcError { code, message }),
56 }
57 }
58}
59
60fn mcp_tool_result(text: &str) -> Value {
62 serde_json::json!({
63 "content": [{"type": "text", "text": text}]
64 })
65}
66
67fn mcp_tool_error(text: &str) -> Value {
69 serde_json::json!({
70 "content": [{"type": "text", "text": text}],
71 "isError": true
72 })
73}
74
75fn verify_alias_exists(alias: &str, config_path: &Path) -> Result<(), Value> {
77 let config = match SshConfigFile::parse(config_path) {
78 Ok(c) => c,
79 Err(e) => return Err(mcp_tool_error(&format!("Failed to parse SSH config: {e}"))),
80 };
81 let exists = config.host_entries().iter().any(|h| h.alias == alias);
82 if !exists {
83 return Err(mcp_tool_error(&format!("Host not found: {alias}")));
84 }
85 Ok(())
86}
87
88fn ssh_exec(
90 alias: &str,
91 config_path: &Path,
92 command: &str,
93 timeout_secs: u64,
94) -> Result<(i32, String, String), Value> {
95 let config_str = config_path.to_string_lossy();
96 let mut child = match std::process::Command::new("ssh")
97 .args([
98 "-F",
99 &config_str,
100 "-o",
101 "ConnectTimeout=10",
102 "-o",
103 "BatchMode=yes",
104 "--",
105 alias,
106 command,
107 ])
108 .stdin(std::process::Stdio::null())
109 .stdout(std::process::Stdio::piped())
110 .stderr(std::process::Stdio::piped())
111 .spawn()
112 {
113 Ok(c) => c,
114 Err(e) => return Err(mcp_tool_error(&format!("Failed to spawn ssh: {e}"))),
115 };
116
117 let timeout = std::time::Duration::from_secs(timeout_secs);
118 let start = std::time::Instant::now();
119 loop {
120 match child.try_wait() {
121 Ok(Some(status)) => {
122 let stdout = child
123 .stdout
124 .take()
125 .map(|mut s| {
126 let mut buf = String::new();
127 std::io::Read::read_to_string(&mut s, &mut buf).ok();
128 buf
129 })
130 .unwrap_or_default();
131 let stderr = child
132 .stderr
133 .take()
134 .map(|mut s| {
135 let mut buf = String::new();
136 std::io::Read::read_to_string(&mut s, &mut buf).ok();
137 buf
138 })
139 .unwrap_or_default();
140 return Ok((status.code().unwrap_or(-1), stdout, stderr));
141 }
142 Ok(None) => {
143 if start.elapsed() > timeout {
144 let _ = child.kill();
145 let _ = child.wait();
146 return Err(mcp_tool_error(&format!(
147 "SSH command timed out after {timeout_secs} seconds"
148 )));
149 }
150 std::thread::sleep(std::time::Duration::from_millis(50));
151 }
152 Err(e) => return Err(mcp_tool_error(&format!("Failed to wait for ssh: {e}"))),
153 }
154 }
155}
156
157pub(crate) fn dispatch(method: &str, params: Option<Value>, config_path: &Path) -> JsonRpcResponse {
159 match method {
160 "initialize" => handle_initialize(),
161 "tools/list" => handle_tools_list(),
162 "tools/call" => handle_tools_call(params, config_path),
163 _ => JsonRpcResponse::error(None, -32601, format!("Method not found: {method}")),
164 }
165}
166
167fn handle_initialize() -> JsonRpcResponse {
168 JsonRpcResponse::success(
169 None,
170 serde_json::json!({
171 "protocolVersion": "2024-11-05",
172 "capabilities": {
173 "tools": {}
174 },
175 "serverInfo": {
176 "name": "purple",
177 "version": env!("CARGO_PKG_VERSION")
178 }
179 }),
180 )
181}
182
183fn handle_tools_list() -> JsonRpcResponse {
184 let tools = serde_json::json!({
185 "tools": [
186 {
187 "name": "list_hosts",
188 "description": "List all SSH hosts available to connect to. Returns alias, hostname, user, port, tags and provider for each host. Use the tag parameter to filter by tag, provider tag or provider name (fuzzy match). Call this first to discover available hosts.",
189 "inputSchema": {
190 "type": "object",
191 "properties": {
192 "tag": {
193 "type": "string",
194 "description": "Filter hosts by tag (fuzzy match against tags, provider_tags and provider name)"
195 }
196 }
197 }
198 },
199 {
200 "name": "get_host",
201 "description": "Get detailed information for a single SSH host including identity file, proxy jump, provider metadata, password source and tunnel count.",
202 "inputSchema": {
203 "type": "object",
204 "properties": {
205 "alias": {
206 "type": "string",
207 "description": "The host alias to look up"
208 }
209 },
210 "required": ["alias"]
211 }
212 },
213 {
214 "name": "run_command",
215 "description": "Run a shell command on a remote host via SSH. Non-interactive (BatchMode). Returns exit code, stdout and stderr. Suitable for diagnostic commands, not interactive programs.",
216 "inputSchema": {
217 "type": "object",
218 "properties": {
219 "alias": {
220 "type": "string",
221 "description": "The host alias to connect to"
222 },
223 "command": {
224 "type": "string",
225 "description": "The command to execute"
226 },
227 "timeout": {
228 "type": "integer",
229 "description": "Timeout in seconds (default 30)",
230 "default": 30,
231 "minimum": 1,
232 "maximum": 300
233 }
234 },
235 "required": ["alias", "command"]
236 }
237 },
238 {
239 "name": "list_containers",
240 "description": "List all Docker or Podman containers on a remote host via SSH. Auto-detects the container runtime. Returns container ID, name, image, state, status and ports.",
241 "inputSchema": {
242 "type": "object",
243 "properties": {
244 "alias": {
245 "type": "string",
246 "description": "The host alias to list containers for"
247 }
248 },
249 "required": ["alias"]
250 }
251 },
252 {
253 "name": "container_action",
254 "description": "Start, stop or restart a Docker or Podman container on a remote host via SSH. Auto-detects the container runtime.",
255 "inputSchema": {
256 "type": "object",
257 "properties": {
258 "alias": {
259 "type": "string",
260 "description": "The host alias"
261 },
262 "container_id": {
263 "type": "string",
264 "description": "The container ID or name"
265 },
266 "action": {
267 "type": "string",
268 "description": "The action to perform",
269 "enum": ["start", "stop", "restart"]
270 }
271 },
272 "required": ["alias", "container_id", "action"]
273 }
274 }
275 ]
276 });
277 JsonRpcResponse::success(None, tools)
278}
279
280fn handle_tools_call(params: Option<Value>, config_path: &Path) -> JsonRpcResponse {
281 let params = match params {
282 Some(p) => p,
283 None => {
284 return JsonRpcResponse::error(
285 None,
286 -32602,
287 "Invalid params: missing params object".to_string(),
288 );
289 }
290 };
291
292 let tool_name = match params.get("name").and_then(|n| n.as_str()) {
293 Some(n) => n,
294 None => {
295 return JsonRpcResponse::error(
296 None,
297 -32602,
298 "Invalid params: missing tool name".to_string(),
299 );
300 }
301 };
302
303 let args = params
304 .get("arguments")
305 .cloned()
306 .unwrap_or(serde_json::json!({}));
307
308 let result = match tool_name {
309 "list_hosts" => tool_list_hosts(&args, config_path),
310 "get_host" => tool_get_host(&args, config_path),
311 "run_command" => tool_run_command(&args, config_path),
312 "list_containers" => tool_list_containers(&args, config_path),
313 "container_action" => tool_container_action(&args, config_path),
314 _ => mcp_tool_error(&format!("Unknown tool: {tool_name}")),
315 };
316
317 JsonRpcResponse::success(None, result)
318}
319
320fn tool_list_hosts(args: &Value, config_path: &Path) -> Value {
321 let config = match SshConfigFile::parse(config_path) {
322 Ok(c) => c,
323 Err(e) => return mcp_tool_error(&format!("Failed to parse SSH config: {e}")),
324 };
325
326 let entries = config.host_entries();
327 let tag_filter = args.get("tag").and_then(|t| t.as_str());
328
329 let hosts: Vec<Value> = entries
330 .iter()
331 .filter(|entry| {
332 if is_host_pattern(&entry.alias) {
334 return false;
335 }
336
337 if let Some(tag) = tag_filter {
339 let tag_lower = tag.to_lowercase();
340 let matches_tags = entry
341 .tags
342 .iter()
343 .any(|t| t.to_lowercase().contains(&tag_lower));
344 let matches_provider_tags = entry
345 .provider_tags
346 .iter()
347 .any(|t| t.to_lowercase().contains(&tag_lower));
348 let matches_provider = entry
349 .provider
350 .as_ref()
351 .is_some_and(|p| p.to_lowercase().contains(&tag_lower));
352 if !matches_tags && !matches_provider_tags && !matches_provider {
353 return false;
354 }
355 }
356
357 true
358 })
359 .map(|entry| {
360 serde_json::json!({
361 "alias": entry.alias,
362 "hostname": entry.hostname,
363 "user": entry.user,
364 "port": entry.port,
365 "tags": entry.tags,
366 "provider": entry.provider,
367 "stale": entry.stale.is_some(),
368 })
369 })
370 .collect();
371
372 let json_str = serde_json::to_string_pretty(&hosts).unwrap_or_default();
373 mcp_tool_result(&json_str)
374}
375
376fn tool_get_host(args: &Value, config_path: &Path) -> Value {
377 let alias = match args.get("alias").and_then(|a| a.as_str()) {
378 Some(a) => a,
379 None => return mcp_tool_error("Missing required parameter: alias"),
380 };
381
382 let config = match SshConfigFile::parse(config_path) {
383 Ok(c) => c,
384 Err(e) => return mcp_tool_error(&format!("Failed to parse SSH config: {e}")),
385 };
386
387 let entries = config.host_entries();
388 let entry = entries.iter().find(|e| e.alias == alias);
389
390 match entry {
391 Some(entry) => {
392 let meta: serde_json::Map<String, Value> = entry
393 .provider_meta
394 .iter()
395 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
396 .collect();
397
398 let host = serde_json::json!({
399 "alias": entry.alias,
400 "hostname": entry.hostname,
401 "user": entry.user,
402 "port": entry.port,
403 "identity_file": entry.identity_file,
404 "proxy_jump": entry.proxy_jump,
405 "tags": entry.tags,
406 "provider_tags": entry.provider_tags,
407 "provider": entry.provider,
408 "provider_meta": meta,
409 "askpass": entry.askpass,
410 "tunnel_count": entry.tunnel_count,
411 "stale": entry.stale.is_some(),
412 });
413
414 let json_str = serde_json::to_string_pretty(&host).unwrap_or_default();
415 mcp_tool_result(&json_str)
416 }
417 None => mcp_tool_error(&format!("Host not found: {alias}")),
418 }
419}
420
421fn tool_run_command(args: &Value, config_path: &Path) -> Value {
422 let alias = match args.get("alias").and_then(|a| a.as_str()) {
423 Some(a) if !a.is_empty() => a,
424 _ => return mcp_tool_error("Missing required parameter: alias"),
425 };
426 let command = match args.get("command").and_then(|c| c.as_str()) {
427 Some(c) if !c.is_empty() => c,
428 _ => return mcp_tool_error("Missing required parameter: command"),
429 };
430 let timeout_secs = args.get("timeout").and_then(|t| t.as_u64()).unwrap_or(30);
431
432 if let Err(e) = verify_alias_exists(alias, config_path) {
433 return e;
434 }
435
436 match ssh_exec(alias, config_path, command, timeout_secs) {
437 Ok((exit_code, stdout, stderr)) => {
438 let result = serde_json::json!({
439 "exit_code": exit_code,
440 "stdout": stdout,
441 "stderr": stderr
442 });
443 let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
444 mcp_tool_result(&json_str)
445 }
446 Err(e) => e,
447 }
448}
449
450fn tool_list_containers(args: &Value, config_path: &Path) -> Value {
451 let alias = match args.get("alias").and_then(|a| a.as_str()) {
452 Some(a) if !a.is_empty() => a,
453 _ => return mcp_tool_error("Missing required parameter: alias"),
454 };
455
456 if let Err(e) = verify_alias_exists(alias, config_path) {
457 return e;
458 }
459
460 let command = crate::containers::container_list_command(None);
462
463 let (exit_code, stdout, stderr) = match ssh_exec(alias, config_path, &command, 30) {
464 Ok(r) => r,
465 Err(e) => return e,
466 };
467
468 if exit_code != 0 {
469 return mcp_tool_error(&format!("SSH command failed: {}", stderr.trim()));
470 }
471
472 match crate::containers::parse_container_output(&stdout, None) {
473 Ok((runtime, containers)) => {
474 let containers_json: Vec<Value> = containers
475 .iter()
476 .map(|c| {
477 serde_json::json!({
478 "id": c.id,
479 "name": c.names,
480 "image": c.image,
481 "state": c.state,
482 "status": c.status,
483 "ports": c.ports,
484 })
485 })
486 .collect();
487 let result = serde_json::json!({
488 "runtime": runtime.as_str(),
489 "containers": containers_json,
490 });
491 let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
492 mcp_tool_result(&json_str)
493 }
494 Err(e) => mcp_tool_error(&e),
495 }
496}
497
498fn tool_container_action(args: &Value, config_path: &Path) -> Value {
499 let alias = match args.get("alias").and_then(|a| a.as_str()) {
500 Some(a) if !a.is_empty() => a,
501 _ => return mcp_tool_error("Missing required parameter: alias"),
502 };
503 let container_id = match args.get("container_id").and_then(|c| c.as_str()) {
504 Some(c) if !c.is_empty() => c,
505 _ => return mcp_tool_error("Missing required parameter: container_id"),
506 };
507 let action_str = match args.get("action").and_then(|a| a.as_str()) {
508 Some(a) => a,
509 None => return mcp_tool_error("Missing required parameter: action"),
510 };
511
512 if let Err(e) = crate::containers::validate_container_id(container_id) {
514 return mcp_tool_error(&e);
515 }
516
517 let action = match action_str {
518 "start" => crate::containers::ContainerAction::Start,
519 "stop" => crate::containers::ContainerAction::Stop,
520 "restart" => crate::containers::ContainerAction::Restart,
521 _ => {
522 return mcp_tool_error(&format!(
523 "Invalid action: {action_str}. Must be start, stop or restart"
524 ));
525 }
526 };
527
528 if let Err(e) = verify_alias_exists(alias, config_path) {
529 return e;
530 }
531
532 let detect_cmd = crate::containers::container_list_command(None);
534
535 let (detect_exit, detect_stdout, _detect_stderr) =
536 match ssh_exec(alias, config_path, &detect_cmd, 30) {
537 Ok(r) => r,
538 Err(e) => return e,
539 };
540
541 if detect_exit != 0 {
542 return mcp_tool_error("Failed to detect container runtime");
543 }
544
545 let runtime = match crate::containers::parse_container_output(&detect_stdout, None) {
546 Ok((rt, _)) => rt,
547 Err(e) => return mcp_tool_error(&format!("Failed to detect container runtime: {e}")),
548 };
549
550 let action_command = crate::containers::container_action_command(runtime, action, container_id);
551
552 let (action_exit, _action_stdout, action_stderr) =
553 match ssh_exec(alias, config_path, &action_command, 30) {
554 Ok(r) => r,
555 Err(e) => return e,
556 };
557
558 if action_exit == 0 {
559 let result = serde_json::json!({
560 "success": true,
561 "message": format!("Container {container_id} {}ed", action_str),
562 });
563 let json_str = serde_json::to_string_pretty(&result).unwrap_or_default();
564 mcp_tool_result(&json_str)
565 } else {
566 mcp_tool_error(&format!(
567 "Container action failed: {}",
568 action_stderr.trim()
569 ))
570 }
571}
572
573pub fn run(config_path: &Path) -> anyhow::Result<()> {
576 let stdin = std::io::stdin();
577 let stdout = std::io::stdout();
578 let reader = stdin.lock();
579 let mut writer = stdout.lock();
580
581 for line in reader.lines() {
582 let line = match line {
583 Ok(l) => l,
584 Err(_) => break,
585 };
586 let trimmed = line.trim();
587 if trimmed.is_empty() {
588 continue;
589 }
590
591 let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
592 Ok(r) => r,
593 Err(_) => {
594 let resp = JsonRpcResponse::error(None, -32700, "Parse error".to_string());
595 let json = serde_json::to_string(&resp)?;
596 writeln!(writer, "{json}")?;
597 writer.flush()?;
598 continue;
599 }
600 };
601
602 if request.id.is_none() {
604 eprintln!("[mcp] notification: {}", request.method);
605 continue;
606 }
607
608 let mut response = dispatch(&request.method, request.params, config_path);
609 response.id = request.id;
610
611 let json = serde_json::to_string(&response)?;
612 writeln!(writer, "{json}")?;
613 writer.flush()?;
614 }
615
616 Ok(())
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
626 fn parse_valid_request() {
627 let json = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#;
628 let req: JsonRpcRequest = serde_json::from_str(json).unwrap();
629 assert_eq!(req.method, "initialize");
630 assert_eq!(req.id, Some(Value::Number(1.into())));
631 }
632
633 #[test]
634 fn parse_notification_no_id() {
635 let json = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#;
636 let req: JsonRpcRequest = serde_json::from_str(json).unwrap();
637 assert!(req.id.is_none());
638 assert!(req.params.is_none());
639 }
640
641 #[test]
642 fn parse_invalid_json() {
643 let result: Result<JsonRpcRequest, _> = serde_json::from_str("not json");
644 assert!(result.is_err());
645 }
646
647 #[test]
648 fn response_success_serialization() {
649 let resp = JsonRpcResponse::success(Some(Value::Number(1.into())), Value::Bool(true));
650 let json = serde_json::to_string(&resp).unwrap();
651 assert!(json.contains(r#""result":true"#));
652 assert!(!json.contains("error"));
653 }
654
655 #[test]
656 fn response_error_serialization() {
657 let resp = JsonRpcResponse::error(
658 Some(Value::Number(1.into())),
659 -32601,
660 "Method not found".to_string(),
661 );
662 let json = serde_json::to_string(&resp).unwrap();
663 assert!(json.contains("-32601"));
664 assert!(!json.contains("result"));
665 }
666
667 #[test]
670 fn test_handle_initialize() {
671 let params = serde_json::json!({
672 "protocolVersion": "2024-11-05",
673 "capabilities": {},
674 "clientInfo": {"name": "test", "version": "1.0"}
675 });
676 let resp = dispatch(
677 "initialize",
678 Some(params),
679 &std::path::PathBuf::from("/dev/null"),
680 );
681 let result = resp.result.unwrap();
682 assert_eq!(result["protocolVersion"], "2024-11-05");
683 assert!(result["capabilities"]["tools"].is_object());
684 assert_eq!(result["serverInfo"]["name"], "purple");
685 }
686
687 #[test]
688 fn test_handle_tools_list() {
689 let resp = dispatch("tools/list", None, &std::path::PathBuf::from("/dev/null"));
690 let result = resp.result.unwrap();
691 let tools = result["tools"].as_array().unwrap();
692 assert_eq!(tools.len(), 5);
693 let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
694 assert!(names.contains(&"list_hosts"));
695 assert!(names.contains(&"get_host"));
696 assert!(names.contains(&"run_command"));
697 assert!(names.contains(&"list_containers"));
698 assert!(names.contains(&"container_action"));
699 }
700
701 #[test]
702 fn test_handle_unknown_method() {
703 let resp = dispatch("bogus/method", None, &std::path::PathBuf::from("/dev/null"));
704 assert!(resp.error.is_some());
705 assert_eq!(resp.error.unwrap().code, -32601);
706 }
707
708 #[test]
711 fn tool_list_hosts_returns_all_concrete_hosts() {
712 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
713 let args = serde_json::json!({});
714 let resp = dispatch(
715 "tools/call",
716 Some(serde_json::json!({"name": "list_hosts", "arguments": args})),
717 &config_path,
718 );
719 let result = resp.result.unwrap();
720 let text = result["content"][0]["text"].as_str().unwrap();
721 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
722 assert_eq!(hosts.len(), 2);
723 assert_eq!(hosts[0]["alias"], "web-1");
724 assert_eq!(hosts[1]["alias"], "db-1");
725 }
726
727 #[test]
728 fn tool_list_hosts_filter_by_tag() {
729 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
730 let args = serde_json::json!({"tag": "database"});
731 let resp = dispatch(
732 "tools/call",
733 Some(serde_json::json!({"name": "list_hosts", "arguments": args})),
734 &config_path,
735 );
736 let result = resp.result.unwrap();
737 let text = result["content"][0]["text"].as_str().unwrap();
738 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
739 assert_eq!(hosts.len(), 1);
740 assert_eq!(hosts[0]["alias"], "db-1");
741 }
742
743 #[test]
744 fn tool_get_host_found() {
745 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
746 let args = serde_json::json!({"alias": "web-1"});
747 let resp = dispatch(
748 "tools/call",
749 Some(serde_json::json!({"name": "get_host", "arguments": args})),
750 &config_path,
751 );
752 let result = resp.result.unwrap();
753 let text = result["content"][0]["text"].as_str().unwrap();
754 let host: Value = serde_json::from_str(text).unwrap();
755 assert_eq!(host["alias"], "web-1");
756 assert_eq!(host["hostname"], "10.0.1.5");
757 assert_eq!(host["user"], "deploy");
758 assert_eq!(host["identity_file"], "~/.ssh/id_ed25519");
759 assert_eq!(host["provider"], "aws");
760 }
761
762 #[test]
763 fn tool_get_host_not_found() {
764 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
765 let args = serde_json::json!({"alias": "nonexistent"});
766 let resp = dispatch(
767 "tools/call",
768 Some(serde_json::json!({"name": "get_host", "arguments": args})),
769 &config_path,
770 );
771 let result = resp.result.unwrap();
772 assert!(result["isError"].as_bool().unwrap());
773 }
774
775 #[test]
776 fn tool_get_host_missing_alias() {
777 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
778 let args = serde_json::json!({});
779 let resp = dispatch(
780 "tools/call",
781 Some(serde_json::json!({"name": "get_host", "arguments": args})),
782 &config_path,
783 );
784 let result = resp.result.unwrap();
785 assert!(result["isError"].as_bool().unwrap());
786 }
787
788 #[test]
791 fn tool_run_command_missing_alias() {
792 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
793 let args = serde_json::json!({"command": "uptime"});
794 let resp = dispatch(
795 "tools/call",
796 Some(serde_json::json!({"name": "run_command", "arguments": args})),
797 &config_path,
798 );
799 let result = resp.result.unwrap();
800 assert!(result["isError"].as_bool().unwrap());
801 }
802
803 #[test]
804 fn tool_run_command_missing_command() {
805 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
806 let args = serde_json::json!({"alias": "web-1"});
807 let resp = dispatch(
808 "tools/call",
809 Some(serde_json::json!({"name": "run_command", "arguments": args})),
810 &config_path,
811 );
812 let result = resp.result.unwrap();
813 assert!(result["isError"].as_bool().unwrap());
814 }
815
816 #[test]
817 fn tool_run_command_empty_alias() {
818 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
819 let args = serde_json::json!({"alias": "", "command": "uptime"});
820 let resp = dispatch(
821 "tools/call",
822 Some(serde_json::json!({"name": "run_command", "arguments": args})),
823 &config_path,
824 );
825 let result = resp.result.unwrap();
826 assert!(result["isError"].as_bool().unwrap());
827 }
828
829 #[test]
830 fn tool_run_command_empty_command() {
831 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
832 let args = serde_json::json!({"alias": "web-1", "command": ""});
833 let resp = dispatch(
834 "tools/call",
835 Some(serde_json::json!({"name": "run_command", "arguments": args})),
836 &config_path,
837 );
838 let result = resp.result.unwrap();
839 assert!(result["isError"].as_bool().unwrap());
840 }
841
842 #[test]
845 fn tool_list_containers_missing_alias() {
846 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
847 let args = serde_json::json!({});
848 let resp = dispatch(
849 "tools/call",
850 Some(serde_json::json!({"name": "list_containers", "arguments": args})),
851 &config_path,
852 );
853 let result = resp.result.unwrap();
854 assert!(result["isError"].as_bool().unwrap());
855 }
856
857 #[test]
858 fn tool_container_action_missing_fields() {
859 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
860 let args = serde_json::json!({"alias": "web-1", "action": "start"});
861 let resp = dispatch(
862 "tools/call",
863 Some(serde_json::json!({"name": "container_action", "arguments": args})),
864 &config_path,
865 );
866 let result = resp.result.unwrap();
867 assert!(result["isError"].as_bool().unwrap());
868 }
869
870 #[test]
871 fn tool_container_action_invalid_action() {
872 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
873 let args =
874 serde_json::json!({"alias": "web-1", "container_id": "abc", "action": "destroy"});
875 let resp = dispatch(
876 "tools/call",
877 Some(serde_json::json!({"name": "container_action", "arguments": args})),
878 &config_path,
879 );
880 let result = resp.result.unwrap();
881 assert!(result["isError"].as_bool().unwrap());
882 }
883
884 #[test]
885 fn tool_container_action_invalid_container_id() {
886 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
887 let args = serde_json::json!({"alias": "web-1", "container_id": "abc;rm -rf /", "action": "start"});
888 let resp = dispatch(
889 "tools/call",
890 Some(serde_json::json!({"name": "container_action", "arguments": args})),
891 &config_path,
892 );
893 let result = resp.result.unwrap();
894 assert!(result["isError"].as_bool().unwrap());
895 }
896
897 #[test]
900 fn tools_call_missing_params() {
901 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
902 let resp = dispatch("tools/call", None, &config_path);
903 assert!(resp.result.is_none());
904 let err = resp.error.unwrap();
905 assert_eq!(err.code, -32602);
906 assert!(err.message.contains("missing params"));
907 }
908
909 #[test]
910 fn tools_call_missing_tool_name() {
911 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
912 let resp = dispatch(
913 "tools/call",
914 Some(serde_json::json!({"arguments": {}})),
915 &config_path,
916 );
917 assert!(resp.result.is_none());
918 let err = resp.error.unwrap();
919 assert_eq!(err.code, -32602);
920 assert!(err.message.contains("missing tool name"));
921 }
922
923 #[test]
924 fn tools_call_unknown_tool() {
925 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
926 let resp = dispatch(
927 "tools/call",
928 Some(serde_json::json!({"name": "nonexistent_tool", "arguments": {}})),
929 &config_path,
930 );
931 let result = resp.result.unwrap();
932 assert!(result["isError"].as_bool().unwrap());
933 assert!(
934 result["content"][0]["text"]
935 .as_str()
936 .unwrap()
937 .contains("Unknown tool")
938 );
939 }
940
941 #[test]
942 fn tools_call_name_is_number_not_string() {
943 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
944 let resp = dispatch(
945 "tools/call",
946 Some(serde_json::json!({"name": 42, "arguments": {}})),
947 &config_path,
948 );
949 assert!(resp.result.is_none());
950 let err = resp.error.unwrap();
951 assert_eq!(err.code, -32602);
952 }
953
954 #[test]
955 fn tools_call_no_arguments_field() {
956 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
958 let resp = dispatch(
959 "tools/call",
960 Some(serde_json::json!({"name": "list_hosts"})),
961 &config_path,
962 );
963 let result = resp.result.unwrap();
964 assert!(result.get("isError").is_none());
966 let text = result["content"][0]["text"].as_str().unwrap();
967 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
968 assert_eq!(hosts.len(), 2);
969 }
970
971 #[test]
974 fn tool_list_hosts_empty_config() {
975 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_empty_config");
976 let resp = dispatch(
977 "tools/call",
978 Some(serde_json::json!({"name": "list_hosts", "arguments": {}})),
979 &config_path,
980 );
981 let result = resp.result.unwrap();
982 let text = result["content"][0]["text"].as_str().unwrap();
983 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
984 assert!(hosts.is_empty());
985 }
986
987 #[test]
988 fn tool_list_hosts_filter_by_provider_name() {
989 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
990 let args = serde_json::json!({"tag": "aws"});
991 let resp = dispatch(
992 "tools/call",
993 Some(serde_json::json!({"name": "list_hosts", "arguments": args})),
994 &config_path,
995 );
996 let result = resp.result.unwrap();
997 let text = result["content"][0]["text"].as_str().unwrap();
998 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
999 assert_eq!(hosts.len(), 1);
1000 assert_eq!(hosts[0]["alias"], "web-1");
1001 }
1002
1003 #[test]
1004 fn tool_list_hosts_filter_case_insensitive() {
1005 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1006 let args = serde_json::json!({"tag": "PROD"});
1007 let resp = dispatch(
1008 "tools/call",
1009 Some(serde_json::json!({"name": "list_hosts", "arguments": args})),
1010 &config_path,
1011 );
1012 let result = resp.result.unwrap();
1013 let text = result["content"][0]["text"].as_str().unwrap();
1014 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
1015 assert_eq!(hosts.len(), 2); }
1017
1018 #[test]
1019 fn tool_list_hosts_filter_no_match() {
1020 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1021 let args = serde_json::json!({"tag": "nonexistent-tag"});
1022 let resp = dispatch(
1023 "tools/call",
1024 Some(serde_json::json!({"name": "list_hosts", "arguments": args})),
1025 &config_path,
1026 );
1027 let result = resp.result.unwrap();
1028 let text = result["content"][0]["text"].as_str().unwrap();
1029 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
1030 assert!(hosts.is_empty());
1031 }
1032
1033 #[test]
1034 fn tool_list_hosts_filter_by_provider_tags() {
1035 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_provider_tags_config");
1036 let args = serde_json::json!({"tag": "backend"});
1037 let resp = dispatch(
1038 "tools/call",
1039 Some(serde_json::json!({"name": "list_hosts", "arguments": args})),
1040 &config_path,
1041 );
1042 let result = resp.result.unwrap();
1043 let text = result["content"][0]["text"].as_str().unwrap();
1044 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
1045 assert_eq!(hosts.len(), 1);
1046 assert_eq!(hosts[0]["alias"], "tagged-1");
1047 }
1048
1049 #[test]
1050 fn tool_list_hosts_stale_field_is_boolean() {
1051 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_stale_config");
1052 let resp = dispatch(
1053 "tools/call",
1054 Some(serde_json::json!({"name": "list_hosts", "arguments": {}})),
1055 &config_path,
1056 );
1057 let result = resp.result.unwrap();
1058 let text = result["content"][0]["text"].as_str().unwrap();
1059 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
1060 let stale_host = hosts.iter().find(|h| h["alias"] == "stale-1").unwrap();
1061 let active_host = hosts.iter().find(|h| h["alias"] == "active-1").unwrap();
1062 assert_eq!(stale_host["stale"], true);
1063 assert_eq!(active_host["stale"], false);
1064 }
1065
1066 #[test]
1067 fn tool_list_hosts_output_fields() {
1068 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1069 let resp = dispatch(
1070 "tools/call",
1071 Some(serde_json::json!({"name": "list_hosts", "arguments": {}})),
1072 &config_path,
1073 );
1074 let result = resp.result.unwrap();
1075 let text = result["content"][0]["text"].as_str().unwrap();
1076 let hosts: Vec<Value> = serde_json::from_str(text).unwrap();
1077 let host = &hosts[0];
1078 assert!(host.get("alias").is_some());
1080 assert!(host.get("hostname").is_some());
1081 assert!(host.get("user").is_some());
1082 assert!(host.get("port").is_some());
1083 assert!(host.get("tags").is_some());
1084 assert!(host.get("provider").is_some());
1085 assert!(host.get("stale").is_some());
1086 assert!(host["port"].is_number());
1088 assert!(host["tags"].is_array());
1089 assert!(host["stale"].is_boolean());
1090 }
1091
1092 #[test]
1095 fn tool_get_host_empty_alias() {
1096 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1097 let args = serde_json::json!({"alias": ""});
1098 let resp = dispatch(
1099 "tools/call",
1100 Some(serde_json::json!({"name": "get_host", "arguments": args})),
1101 &config_path,
1102 );
1103 let result = resp.result.unwrap();
1104 assert!(
1107 result["isError"].as_bool().unwrap_or(false) || {
1108 let text = result["content"][0]["text"].as_str().unwrap_or("");
1109 text.contains("not found") || text.contains("Missing")
1110 }
1111 );
1112 }
1113
1114 #[test]
1115 fn tool_get_host_alias_is_number() {
1116 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1117 let args = serde_json::json!({"alias": 42});
1118 let resp = dispatch(
1119 "tools/call",
1120 Some(serde_json::json!({"name": "get_host", "arguments": args})),
1121 &config_path,
1122 );
1123 let result = resp.result.unwrap();
1124 assert!(result["isError"].as_bool().unwrap());
1125 }
1126
1127 #[test]
1128 fn tool_get_host_output_fields() {
1129 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1130 let args = serde_json::json!({"alias": "web-1"});
1131 let resp = dispatch(
1132 "tools/call",
1133 Some(serde_json::json!({"name": "get_host", "arguments": args})),
1134 &config_path,
1135 );
1136 let result = resp.result.unwrap();
1137 let text = result["content"][0]["text"].as_str().unwrap();
1138 let host: Value = serde_json::from_str(text).unwrap();
1139 assert_eq!(host["port"], 22);
1141 assert!(host["tags"].is_array());
1142 assert!(host["provider_tags"].is_array());
1143 assert!(host["provider_meta"].is_object());
1144 assert!(host["stale"].is_boolean());
1145 assert_eq!(host["stale"], false);
1146 assert_eq!(host["tunnel_count"], 0);
1147 assert_eq!(host["provider_meta"]["region"], "us-east-1");
1149 assert_eq!(host["provider_meta"]["instance"], "t3.micro");
1150 }
1151
1152 #[test]
1153 fn tool_get_host_no_provider() {
1154 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1155 let args = serde_json::json!({"alias": "db-1"});
1156 let resp = dispatch(
1157 "tools/call",
1158 Some(serde_json::json!({"name": "get_host", "arguments": args})),
1159 &config_path,
1160 );
1161 let result = resp.result.unwrap();
1162 let text = result["content"][0]["text"].as_str().unwrap();
1163 let host: Value = serde_json::from_str(text).unwrap();
1164 assert!(host["provider"].is_null());
1165 assert!(host["provider_meta"].as_object().unwrap().is_empty());
1166 assert_eq!(host["port"], 5432);
1167 }
1168
1169 #[test]
1170 fn tool_get_host_stale_is_boolean() {
1171 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_stale_config");
1172 let args = serde_json::json!({"alias": "stale-1"});
1173 let resp = dispatch(
1174 "tools/call",
1175 Some(serde_json::json!({"name": "get_host", "arguments": args})),
1176 &config_path,
1177 );
1178 let result = resp.result.unwrap();
1179 let text = result["content"][0]["text"].as_str().unwrap();
1180 let host: Value = serde_json::from_str(text).unwrap();
1181 assert_eq!(host["stale"], true);
1182 }
1183
1184 #[test]
1185 fn tool_get_host_case_sensitive() {
1186 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1187 let args = serde_json::json!({"alias": "WEB-1"});
1188 let resp = dispatch(
1189 "tools/call",
1190 Some(serde_json::json!({"name": "get_host", "arguments": args})),
1191 &config_path,
1192 );
1193 let result = resp.result.unwrap();
1194 assert!(result["isError"].as_bool().unwrap());
1195 }
1196
1197 #[test]
1200 fn tool_run_command_nonexistent_alias() {
1201 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1202 let args = serde_json::json!({"alias": "nonexistent-host", "command": "uptime"});
1203 let resp = dispatch(
1204 "tools/call",
1205 Some(serde_json::json!({"name": "run_command", "arguments": args})),
1206 &config_path,
1207 );
1208 let result = resp.result.unwrap();
1209 assert!(result["isError"].as_bool().unwrap());
1210 assert!(
1211 result["content"][0]["text"]
1212 .as_str()
1213 .unwrap()
1214 .contains("not found")
1215 );
1216 }
1217
1218 #[test]
1219 fn tool_run_command_alias_is_number() {
1220 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1221 let args = serde_json::json!({"alias": 42, "command": "uptime"});
1222 let resp = dispatch(
1223 "tools/call",
1224 Some(serde_json::json!({"name": "run_command", "arguments": args})),
1225 &config_path,
1226 );
1227 let result = resp.result.unwrap();
1228 assert!(result["isError"].as_bool().unwrap());
1229 }
1230
1231 #[test]
1232 fn tool_run_command_command_is_number() {
1233 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1234 let args = serde_json::json!({"alias": "web-1", "command": 123});
1235 let resp = dispatch(
1236 "tools/call",
1237 Some(serde_json::json!({"name": "run_command", "arguments": args})),
1238 &config_path,
1239 );
1240 let result = resp.result.unwrap();
1241 assert!(result["isError"].as_bool().unwrap());
1242 }
1243
1244 #[test]
1245 fn tool_run_command_timeout_is_string() {
1246 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1248 let args =
1249 serde_json::json!({"alias": "web-1", "command": "uptime", "timeout": "not-a-number"});
1250 let resp = dispatch(
1251 "tools/call",
1252 Some(serde_json::json!({"name": "run_command", "arguments": args})),
1253 &config_path,
1254 );
1255 let result = resp.result.unwrap();
1258 let text = result["content"][0]["text"].as_str().unwrap();
1260 assert!(!text.contains("Missing required parameter"));
1261 }
1262
1263 #[test]
1266 fn tool_container_action_empty_alias() {
1267 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1268 let args = serde_json::json!({"alias": "", "container_id": "abc", "action": "start"});
1269 let resp = dispatch(
1270 "tools/call",
1271 Some(serde_json::json!({"name": "container_action", "arguments": args})),
1272 &config_path,
1273 );
1274 let result = resp.result.unwrap();
1275 assert!(result["isError"].as_bool().unwrap());
1276 }
1277
1278 #[test]
1279 fn tool_container_action_empty_container_id() {
1280 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1281 let args = serde_json::json!({"alias": "web-1", "container_id": "", "action": "start"});
1282 let resp = dispatch(
1283 "tools/call",
1284 Some(serde_json::json!({"name": "container_action", "arguments": args})),
1285 &config_path,
1286 );
1287 let result = resp.result.unwrap();
1288 assert!(result["isError"].as_bool().unwrap());
1289 }
1290
1291 #[test]
1292 fn tool_container_action_nonexistent_alias() {
1293 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1294 let args =
1295 serde_json::json!({"alias": "nonexistent", "container_id": "abc", "action": "start"});
1296 let resp = dispatch(
1297 "tools/call",
1298 Some(serde_json::json!({"name": "container_action", "arguments": args})),
1299 &config_path,
1300 );
1301 let result = resp.result.unwrap();
1302 assert!(result["isError"].as_bool().unwrap());
1303 assert!(
1304 result["content"][0]["text"]
1305 .as_str()
1306 .unwrap()
1307 .contains("not found")
1308 );
1309 }
1310
1311 #[test]
1312 fn tool_container_action_uppercase_action() {
1313 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1314 let args = serde_json::json!({"alias": "web-1", "container_id": "abc", "action": "START"});
1315 let resp = dispatch(
1316 "tools/call",
1317 Some(serde_json::json!({"name": "container_action", "arguments": args})),
1318 &config_path,
1319 );
1320 let result = resp.result.unwrap();
1321 assert!(result["isError"].as_bool().unwrap());
1322 assert!(
1323 result["content"][0]["text"]
1324 .as_str()
1325 .unwrap()
1326 .contains("Invalid action")
1327 );
1328 }
1329
1330 #[test]
1331 fn tool_container_action_container_id_with_dots_and_hyphens() {
1332 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1334 let args = serde_json::json!({"alias": "web-1", "container_id": "my-container_v1.2", "action": "start"});
1335 let resp = dispatch(
1336 "tools/call",
1337 Some(serde_json::json!({"name": "container_action", "arguments": args})),
1338 &config_path,
1339 );
1340 let result = resp.result.unwrap();
1341 let text = result["content"][0]["text"].as_str().unwrap();
1344 assert!(!text.contains("invalid character"));
1345 }
1346
1347 #[test]
1348 fn tool_container_action_container_id_with_spaces() {
1349 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1350 let args = serde_json::json!({"alias": "web-1", "container_id": "my container", "action": "start"});
1351 let resp = dispatch(
1352 "tools/call",
1353 Some(serde_json::json!({"name": "container_action", "arguments": args})),
1354 &config_path,
1355 );
1356 let result = resp.result.unwrap();
1357 assert!(result["isError"].as_bool().unwrap());
1358 assert!(
1359 result["content"][0]["text"]
1360 .as_str()
1361 .unwrap()
1362 .contains("invalid character")
1363 );
1364 }
1365
1366 #[test]
1367 fn tool_list_containers_missing_empty_alias() {
1368 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1369 let args = serde_json::json!({"alias": ""});
1370 let resp = dispatch(
1371 "tools/call",
1372 Some(serde_json::json!({"name": "list_containers", "arguments": args})),
1373 &config_path,
1374 );
1375 let result = resp.result.unwrap();
1376 assert!(result["isError"].as_bool().unwrap());
1377 }
1378
1379 #[test]
1380 fn tool_list_containers_nonexistent_alias() {
1381 let config_path = std::path::PathBuf::from("tests/fixtures/mcp_test_config");
1382 let args = serde_json::json!({"alias": "nonexistent"});
1383 let resp = dispatch(
1384 "tools/call",
1385 Some(serde_json::json!({"name": "list_containers", "arguments": args})),
1386 &config_path,
1387 );
1388 let result = resp.result.unwrap();
1389 assert!(result["isError"].as_bool().unwrap());
1390 assert!(
1391 result["content"][0]["text"]
1392 .as_str()
1393 .unwrap()
1394 .contains("not found")
1395 );
1396 }
1397
1398 #[test]
1401 fn initialize_contains_version() {
1402 let resp = dispatch("initialize", None, &std::path::PathBuf::from("/dev/null"));
1403 let result = resp.result.unwrap();
1404 assert!(!result["serverInfo"]["version"].as_str().unwrap().is_empty());
1405 }
1406
1407 #[test]
1408 fn tools_list_schema_has_required_fields() {
1409 let resp = dispatch("tools/list", None, &std::path::PathBuf::from("/dev/null"));
1410 let result = resp.result.unwrap();
1411 let tools = result["tools"].as_array().unwrap();
1412 for tool in tools {
1413 assert!(tool["name"].is_string(), "Tool missing name");
1414 assert!(tool["description"].is_string(), "Tool missing description");
1415 assert!(tool["inputSchema"].is_object(), "Tool missing inputSchema");
1416 assert_eq!(tool["inputSchema"]["type"], "object");
1417 }
1418 }
1419}