Skip to main content

purple_ssh/
mcp.rs

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/// A JSON-RPC 2.0 request.
11#[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/// A JSON-RPC 2.0 response.
23#[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/// A JSON-RPC 2.0 error object.
35#[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
61/// Helper to build an MCP tool result (success).
62fn mcp_tool_result(text: &str) -> Value {
63    serde_json::json!({
64        "content": [{"type": "text", "text": text}]
65    })
66}
67
68/// Helper to build an MCP tool error result.
69fn mcp_tool_error(text: &str) -> Value {
70    serde_json::json!({
71        "content": [{"type": "text", "text": text}],
72        "isError": true
73    })
74}
75
76/// Verify that an alias exists in the SSH config. Returns error Value if not found.
77fn 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
89/// Run an SSH command with a timeout. Returns (exit_code, stdout, stderr).
90fn 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
158/// Dispatch a JSON-RPC method to the appropriate handler.
159pub(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            // Skip host patterns (already filtered by host_entries, but be safe)
334            if is_host_pattern(&entry.alias) {
335                return false;
336            }
337
338            // Apply tag filter (fuzzy: substring match on tags, provider_tags, provider name)
339            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    // Build the combined detection + listing command
466    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    // Validate container ID (injection prevention)
518    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    // First detect runtime
538    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
578/// Run the MCP server, reading JSON-RPC requests from stdin and writing
579/// responses to stdout. Blocks until stdin is closed.
580pub 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        // Notifications (no id) don't get responses
608        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    // --- Task 1: JSON-RPC types and parsing ---
635
636    #[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    // --- Task 2: MCP initialize and tools/list handlers ---
679
680    #[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    // --- Task 3: list_hosts and get_host tool handlers ---
720
721    #[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    // --- Task 4: run_command tool handler ---
800
801    #[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    // --- Task 5: list_containers and container_action tool handlers ---
854
855    #[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    // --- Protocol-level tests ---
909
910    #[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        // arguments defaults to {} when missing
968        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        // Should succeed - list_hosts with no args returns all hosts
976        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    // --- list_hosts additional tests ---
983
984    #[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); // both web-1 and db-1 have "prod" tag
1027    }
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        // Verify all expected fields are present
1090        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        // Verify types
1098        assert!(host["port"].is_number());
1099        assert!(host["tags"].is_array());
1100        assert!(host["stale"].is_boolean());
1101    }
1102
1103    // --- get_host additional tests ---
1104
1105    #[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        // get_host doesn't check for empty string (unlike run_command), just does lookup
1116        // Empty string won't match any host
1117        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        // Verify all expected fields
1151        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        // Verify provider_meta content
1159        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    // --- run_command additional tests ---
1209
1210    #[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        // timeout as string should be ignored, defaulting to 30
1258        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        // This should not error on parsing - timeout defaults to 30
1267        // It will fail on SSH but not on input validation
1268        let result = resp.result.unwrap();
1269        // The alias exists so it will try SSH (which may fail), but no input validation error
1270        let text = result["content"][0]["text"].as_str().unwrap();
1271        assert!(!text.contains("Missing required parameter"));
1272    }
1273
1274    // --- container_action additional tests ---
1275
1276    #[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        // Valid container IDs can have dots, hyphens, underscores
1344        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        // Should NOT error on validation - container_id is valid
1353        // Will proceed to alias check and SSH (which may fail), but no validation error
1354        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    // --- initialize and tools/list output tests ---
1410
1411    #[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}