Skip to main content

purple_ssh/
mcp.rs

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