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