Skip to main content

purple_ssh/
mcp.rs

1use std::fs::{File, OpenOptions};
2use std::io::{BufRead, Write};
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, OnceLock};
5use std::time::{SystemTime, UNIX_EPOCH};
6
7use log::{debug, error, info, warn};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::messages;
12use crate::ssh_config::model::{SshConfigFile, is_host_pattern};
13
14/// Tools allowed when the server is started with `--read-only`.
15/// State-changing tools (`run_command`, `container_action`) are excluded.
16const READ_ONLY_TOOLS: &[&str] = &["list_hosts", "get_host", "list_containers"];
17
18/// Runtime options for the MCP server. Built from CLI flags by `main`.
19#[derive(Debug, Clone, Default)]
20pub struct McpOptions {
21    /// When true, only read-only tools are exposed; state-changing tools are
22    /// removed from `tools/list` and rejected from `tools/call`.
23    pub read_only: bool,
24    /// Path for the audit log. `None` disables audit logging.
25    pub audit_log_path: Option<PathBuf>,
26}
27
28/// Context passed to dispatch and tool handlers. Holds the SSH config path,
29/// runtime options, and an optional audit log handle.
30///
31/// Fields are crate-visible so call sites inside `mcp` can read them, but
32/// the type itself is `pub` so `main` and the in-module tests can construct
33/// one.
34pub struct McpContext {
35    pub(crate) config_path: PathBuf,
36    pub(crate) options: McpOptions,
37    pub(crate) audit: Option<AuditLog>,
38}
39
40impl McpContext {
41    pub fn new(config_path: PathBuf, options: McpOptions) -> Self {
42        let audit = options
43            .audit_log_path
44            .as_deref()
45            .and_then(|path| match AuditLog::open(path) {
46                Ok(log) => Some(log),
47                Err(e) => {
48                    let body = messages::mcp_audit_init_failed(&path.display(), &e);
49                    eprintln!("{body}");
50                    warn!("[purple] {body}");
51                    None
52                }
53            });
54        Self {
55            config_path,
56            options,
57            audit,
58        }
59    }
60
61    /// True when a tool name may be called in the current mode.
62    fn is_tool_allowed(&self, tool: &str) -> bool {
63        !self.options.read_only || READ_ONLY_TOOLS.contains(&tool)
64    }
65}
66
67/// Append-only JSON Lines audit log. One file handle, serialised by a
68/// `Mutex` so concurrent writes from future multi-threaded clients stay
69/// atomic on POSIX. Each entry records timestamp, tool name, sanitised
70/// arguments, and outcome.
71pub struct AuditLog {
72    file: Mutex<File>,
73}
74
75impl AuditLog {
76    pub fn open(path: &Path) -> std::io::Result<Self> {
77        if let Some(parent) = path.parent() {
78            if !parent.as_os_str().is_empty() {
79                std::fs::create_dir_all(parent)?;
80                // Note: we do not chmod the parent. It may be a user-chosen
81                // location (e.g. /tmp) that we have no business locking down.
82                // The log file itself is set to 0o600 below.
83            }
84        }
85        // Refuse to open a path that already exists as a symlink: an attacker
86        // who can pre-create a symlink in a writable location could redirect
87        // the log into a sensitive file (cron, ssh authorized_keys, etc.).
88        if let Ok(meta) = std::fs::symlink_metadata(path) {
89            if meta.file_type().is_symlink() {
90                return Err(std::io::Error::new(
91                    std::io::ErrorKind::PermissionDenied,
92                    "audit log path is a symlink; refusing to open",
93                ));
94            }
95        }
96        let file = OpenOptions::new().create(true).append(true).open(path)?;
97        // Restrict to owner read/write. The log can carry host aliases and
98        // tool arguments and must not be world-readable. Best-effort: if
99        // chmod fails (rare on supported platforms) we still proceed.
100        #[cfg(unix)]
101        {
102            use std::os::unix::fs::PermissionsExt;
103            let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
104        }
105        Ok(Self {
106            file: Mutex::new(file),
107        })
108    }
109
110    /// Append one audit entry. Failures are logged but never propagated:
111    /// audit-log write errors must not break the JSON-RPC response loop.
112    /// Args are redacted before logging so shell command bodies and other
113    /// fields that may carry secrets are not persisted.
114    //
115    // Append-only log: `fs_util::atomic_write` would truncate-and-replace,
116    // which destroys prior entries. Direct `writeln!` + `flush` is correct
117    // here. POSIX guarantees small (<PIPE_BUF) writes against an `O_APPEND`
118    // fd are atomic, so concurrent writers do not interleave lines.
119    pub fn record(&self, tool: &str, args: &Value, outcome: AuditOutcome) {
120        let entry = serde_json::json!({
121            "ts": iso8601_now(),
122            "tool": tool,
123            "args": redact_args_for_audit(tool, args),
124            "outcome": outcome.label(),
125            "reason": outcome.reason(),
126        });
127        let line = match serde_json::to_string(&entry) {
128            Ok(s) => s,
129            Err(e) => {
130                warn!("[purple] {}", messages::mcp_audit_write_failed(&e));
131                // (return below)
132                return;
133            }
134        };
135        let mut guard = match self.file.lock() {
136            Ok(g) => g,
137            Err(poisoned) => poisoned.into_inner(),
138        };
139        if let Err(e) = writeln!(*guard, "{line}") {
140            warn!("[purple] {}", messages::mcp_audit_write_failed(&e));
141            return;
142        }
143        if let Err(e) = guard.flush() {
144            warn!("[purple] {}", messages::mcp_audit_write_failed(&e));
145        }
146    }
147}
148
149#[derive(Debug, Clone, Copy)]
150pub enum AuditOutcome {
151    Allowed,
152    Denied,
153    Error,
154}
155
156impl AuditOutcome {
157    fn label(self) -> &'static str {
158        match self {
159            AuditOutcome::Allowed => "allowed",
160            AuditOutcome::Denied => "denied",
161            AuditOutcome::Error => "error",
162        }
163    }
164    fn reason(self) -> Option<&'static str> {
165        match self {
166            AuditOutcome::Denied => Some("read-only mode"),
167            _ => None,
168        }
169    }
170}
171
172/// Strip fields that may carry secrets before persisting tool args to the
173/// audit log. The log is a security record, not a debugger; the value of an
174/// audited entry is "what tool was called, on which host, with what
175/// outcome", not the literal command body.
176///
177/// For `run_command` the entire args value is replaced with a marker when it
178/// is not the expected object shape, since a malformed client could send a
179/// string or array containing the secret in any position.
180fn redact_args_for_audit(tool: &str, args: &Value) -> Value {
181    if tool != "run_command" {
182        return args.clone();
183    }
184    let mut redacted = args.clone();
185    match redacted.as_object_mut() {
186        Some(obj) => {
187            if obj.contains_key("command") {
188                obj.insert(
189                    "command".to_string(),
190                    Value::String("<redacted>".to_string()),
191                );
192            }
193        }
194        None => {
195            // Non-object payload: we cannot reason about which subfield holds
196            // the command body, so redact the whole thing.
197            redacted = Value::String("<redacted: non-object args>".to_string());
198        }
199    }
200    redacted
201}
202
203/// RFC 3339 / ISO 8601 UTC timestamp with second precision built from
204/// `SystemTime`. Avoids pulling in chrono for a single format use.
205fn iso8601_now() -> String {
206    let secs = SystemTime::now()
207        .duration_since(UNIX_EPOCH)
208        .map(|d| d.as_secs())
209        .unwrap_or(0);
210    format_iso8601_utc(secs)
211}
212
213fn format_iso8601_utc(secs: u64) -> String {
214    let days_since_epoch = secs / 86_400;
215    let day_secs = secs % 86_400;
216    let hour = day_secs / 3600;
217    let minute = (day_secs % 3600) / 60;
218    let second = day_secs % 60;
219    let (year, month, day) = civil_from_days(days_since_epoch as i64);
220    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
221}
222
223/// Howard Hinnant's date algorithm. Converts days since 1970-01-01 (UTC)
224/// to a (year, month, day) triple. Public-domain, gregorian calendar.
225fn civil_from_days(z: i64) -> (i64, u32, u32) {
226    let z = z + 719_468;
227    let era = z.div_euclid(146_097);
228    let doe = (z - era * 146_097) as u64;
229    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
230    let y = yoe as i64 + era * 400;
231    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
232    let mp = (5 * doy + 2) / 153;
233    let d = doy - (153 * mp + 2) / 5 + 1;
234    let m = if mp < 10 { mp + 3 } else { mp - 9 };
235    let y = if m <= 2 { y + 1 } else { y };
236    (y, m as u32, d as u32)
237}
238
239/// Resolve the default audit log path (`~/.purple/mcp-audit.log`).
240pub fn default_audit_log_path() -> Option<PathBuf> {
241    audit_log_path_from_home(dirs::home_dir())
242}
243
244/// Helper extracted so the `home_dir = None` branch is unit-testable.
245/// Production callers use `default_audit_log_path()`.
246fn audit_log_path_from_home(home: Option<PathBuf>) -> Option<PathBuf> {
247    match home {
248        Some(h) => Some(h.join(".purple").join("mcp-audit.log")),
249        None => {
250            warn!("[purple] {}", messages::MCP_AUDIT_HOME_DIR_UNAVAILABLE);
251            None
252        }
253    }
254}
255
256/// A JSON-RPC 2.0 request.
257#[derive(Debug, Deserialize)]
258pub struct JsonRpcRequest {
259    #[allow(dead_code)]
260    pub jsonrpc: String,
261    #[serde(default)]
262    pub id: Option<Value>,
263    pub method: String,
264    #[serde(default)]
265    pub params: Option<Value>,
266}
267
268/// A JSON-RPC 2.0 response.
269#[derive(Debug, Serialize)]
270pub struct JsonRpcResponse {
271    pub jsonrpc: String,
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub id: Option<Value>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub result: Option<Value>,
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub error: Option<JsonRpcError>,
278}
279
280/// A JSON-RPC 2.0 error object.
281#[derive(Debug, Serialize)]
282pub struct JsonRpcError {
283    pub code: i64,
284    pub message: String,
285}
286
287impl JsonRpcResponse {
288    fn success(id: Option<Value>, result: Value) -> Self {
289        Self {
290            jsonrpc: "2.0".to_string(),
291            id,
292            result: Some(result),
293            error: None,
294        }
295    }
296
297    fn error(id: Option<Value>, code: i64, message: String) -> Self {
298        Self {
299            jsonrpc: "2.0".to_string(),
300            id,
301            result: None,
302            error: Some(JsonRpcError { code, message }),
303        }
304    }
305}
306
307/// Helper to build an MCP tool result (success).
308fn mcp_tool_result(text: &str) -> Value {
309    serde_json::json!({
310        "content": [{"type": "text", "text": text}]
311    })
312}
313
314/// Helper to build an MCP tool error result.
315fn mcp_tool_error(text: &str) -> Value {
316    serde_json::json!({
317        "content": [{"type": "text", "text": text}],
318        "isError": true
319    })
320}
321
322/// Surface a missing config file as an explicit MCP error rather than letting
323/// the parser silently produce an empty config (the failure mode that caused
324/// the .mcpb-with-unexpanded-${HOME} bug to return `[]` instead of erroring).
325fn require_config_exists(config_path: &Path) -> Result<(), Value> {
326    if !config_path.exists() {
327        return Err(mcp_tool_error(&messages::mcp_config_file_not_found(
328            &config_path.display(),
329        )));
330    }
331    Ok(())
332}
333
334/// Verify that an alias exists in the SSH config. Returns error Value if not found.
335fn verify_alias_exists(alias: &str, config_path: &Path) -> Result<(), Value> {
336    require_config_exists(config_path)?;
337    let config = match SshConfigFile::parse(config_path) {
338        Ok(c) => c,
339        Err(e) => return Err(mcp_tool_error(&format!("Failed to parse SSH config: {e}"))),
340    };
341    let exists = config.host_entries().iter().any(|h| h.alias == alias);
342    if !exists {
343        return Err(mcp_tool_error(&format!("Host not found: {alias}")));
344    }
345    Ok(())
346}
347
348/// Run an SSH command with a timeout. Returns (exit_code, stdout, stderr).
349fn ssh_exec(
350    alias: &str,
351    config_path: &Path,
352    command: &str,
353    timeout_secs: u64,
354) -> Result<(i32, String, String), Value> {
355    let config_str = config_path.to_string_lossy();
356    let child = match std::process::Command::new("ssh")
357        .args([
358            "-F",
359            &config_str,
360            "-o",
361            "ConnectTimeout=10",
362            "-o",
363            "BatchMode=yes",
364            "--",
365            alias,
366            command,
367        ])
368        .stdin(std::process::Stdio::null())
369        .stdout(std::process::Stdio::piped())
370        .stderr(std::process::Stdio::piped())
371        .spawn()
372    {
373        Ok(c) => c,
374        Err(e) => return Err(mcp_tool_error(&format!("Failed to spawn ssh: {e}"))),
375    };
376
377    // Wait with timeout via mpsc instead of busy-polling. The waiter thread
378    // owns the child and reads its stdout/stderr to completion via
379    // `wait_with_output`. The main thread blocks on `recv_timeout`. On
380    // timeout we kill the orphan process by PID via `kill(1)` since we no
381    // longer hold a `Child` handle here. POSIX-only path is acceptable: the
382    // project runs on macOS and Linux.
383    let pid = child.id();
384    let (tx, rx) = std::sync::mpsc::channel();
385    std::thread::spawn(move || {
386        let _ = tx.send(child.wait_with_output());
387    });
388
389    match rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) {
390        Ok(Ok(out)) => {
391            let exit = out.status.code().unwrap_or(-1);
392            let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
393            let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
394            Ok((exit, stdout, stderr))
395        }
396        Ok(Err(e)) => Err(mcp_tool_error(&format!("Failed to wait for ssh: {e}"))),
397        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
398            #[cfg(unix)]
399            {
400                let _ = std::process::Command::new("kill")
401                    .arg("-TERM")
402                    .arg(pid.to_string())
403                    .status();
404            }
405            warn!("[external] MCP SSH command timed out after {timeout_secs}s (pid {pid})");
406            Err(mcp_tool_error(&format!(
407                "SSH command timed out after {timeout_secs} seconds"
408            )))
409        }
410        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
411            Err(mcp_tool_error("ssh waiter thread disconnected"))
412        }
413    }
414}
415
416/// Dispatch a JSON-RPC method to the appropriate handler.
417pub(crate) fn dispatch(method: &str, params: Option<Value>, ctx: &McpContext) -> JsonRpcResponse {
418    match method {
419        "initialize" => handle_initialize(),
420        "tools/list" => handle_tools_list(ctx),
421        "tools/call" => handle_tools_call(params, ctx),
422        _ => JsonRpcResponse::error(None, -32601, format!("Method not found: {method}")),
423    }
424}
425
426fn handle_initialize() -> JsonRpcResponse {
427    JsonRpcResponse::success(
428        None,
429        serde_json::json!({
430            "protocolVersion": "2024-11-05",
431            "capabilities": {
432                "tools": {}
433            },
434            "serverInfo": {
435                "name": "purple",
436                "version": env!("CARGO_PKG_VERSION")
437            }
438        }),
439    )
440}
441
442fn handle_tools_list(ctx: &McpContext) -> JsonRpcResponse {
443    let all_tools = all_tools_descriptor();
444    let tools = if ctx.options.read_only {
445        let filtered: Vec<Value> = all_tools
446            .as_array()
447            .map(|arr| {
448                arr.iter()
449                    .filter(|t| {
450                        t.get("name")
451                            .and_then(|n| n.as_str())
452                            .map(|n| READ_ONLY_TOOLS.contains(&n))
453                            .unwrap_or(false)
454                    })
455                    .cloned()
456                    .collect()
457            })
458            .unwrap_or_default();
459        serde_json::json!({ "tools": filtered })
460    } else {
461        serde_json::json!({ "tools": all_tools })
462    };
463    JsonRpcResponse::success(None, tools)
464}
465
466/// Static descriptor cached on first call. Building the JSON once instead of
467/// per-request avoids ~90 lines of `serde_json::json!` allocation on every
468/// `tools/list`. Returns a borrow; callers clone if they need ownership.
469fn all_tools_descriptor() -> &'static Value {
470    static DESCRIPTOR: OnceLock<Value> = OnceLock::new();
471    DESCRIPTOR.get_or_init(build_all_tools_descriptor)
472}
473
474fn build_all_tools_descriptor() -> Value {
475    serde_json::json!([
476        {
477            "name": "list_hosts",
478            "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.",
479            "annotations": {
480                "title": "List SSH hosts",
481                "readOnlyHint": true,
482                "destructiveHint": false,
483                "idempotentHint": true,
484                "openWorldHint": false
485            },
486            "inputSchema": {
487                "type": "object",
488                "properties": {
489                    "tag": {
490                        "type": "string",
491                        "description": "Filter hosts by tag (fuzzy match against tags, provider_tags and provider name)"
492                    }
493                }
494            }
495        },
496        {
497            "name": "get_host",
498            "description": "Get detailed information for a single SSH host including identity file, proxy jump, provider metadata, password source and tunnel count.",
499            "annotations": {
500                "title": "Get SSH host details",
501                "readOnlyHint": true,
502                "destructiveHint": false,
503                "idempotentHint": true,
504                "openWorldHint": false
505            },
506            "inputSchema": {
507                "type": "object",
508                "properties": {
509                    "alias": {
510                        "type": "string",
511                        "description": "The host alias to look up"
512                    }
513                },
514                "required": ["alias"]
515            }
516        },
517        {
518            "name": "run_command",
519            "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.",
520            "annotations": {
521                "title": "Run shell command on SSH host",
522                "readOnlyHint": false,
523                "destructiveHint": true,
524                "idempotentHint": false,
525                "openWorldHint": true
526            },
527            "inputSchema": {
528                "type": "object",
529                "properties": {
530                    "alias": {
531                        "type": "string",
532                        "description": "The host alias to connect to"
533                    },
534                    "command": {
535                        "type": "string",
536                        "description": "The command to execute"
537                    },
538                    "timeout": {
539                        "type": "integer",
540                        "description": "Timeout in seconds (default 30)",
541                        "default": 30,
542                        "minimum": 1,
543                        "maximum": 300
544                    }
545                },
546                "required": ["alias", "command"]
547            }
548        },
549        {
550            "name": "list_containers",
551            "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.",
552            "annotations": {
553                "title": "List containers on SSH host",
554                "readOnlyHint": true,
555                "destructiveHint": false,
556                "idempotentHint": true,
557                "openWorldHint": false
558            },
559            "inputSchema": {
560                "type": "object",
561                "properties": {
562                    "alias": {
563                        "type": "string",
564                        "description": "The host alias to list containers for"
565                    }
566                },
567                "required": ["alias"]
568            }
569        },
570        {
571            "name": "container_action",
572            "description": "Start, stop or restart a Docker or Podman container on a remote host via SSH. Auto-detects the container runtime.",
573            "annotations": {
574                "title": "Start, stop or restart container",
575                "readOnlyHint": false,
576                "destructiveHint": true,
577                "idempotentHint": false,
578                "openWorldHint": false
579            },
580            "inputSchema": {
581                "type": "object",
582                "properties": {
583                    "alias": {
584                        "type": "string",
585                        "description": "The host alias"
586                    },
587                    "container_id": {
588                        "type": "string",
589                        "description": "The container ID or name"
590                    },
591                    "action": {
592                        "type": "string",
593                        "description": "The action to perform",
594                        "enum": ["start", "stop", "restart"]
595                    }
596                },
597                "required": ["alias", "container_id", "action"]
598            }
599        }
600    ])
601}
602
603fn handle_tools_call(params: Option<Value>, ctx: &McpContext) -> JsonRpcResponse {
604    let params = match params {
605        Some(p) => p,
606        None => {
607            return JsonRpcResponse::error(
608                None,
609                -32602,
610                "Invalid params: missing params object".to_string(),
611            );
612        }
613    };
614
615    let tool_name = match params.get("name").and_then(|n| n.as_str()) {
616        Some(n) => n,
617        None => {
618            return JsonRpcResponse::error(
619                None,
620                -32602,
621                "Invalid params: missing tool name".to_string(),
622            );
623        }
624    };
625
626    let args = params
627        .get("arguments")
628        .cloned()
629        .unwrap_or(serde_json::json!({}));
630
631    if !ctx.is_tool_allowed(tool_name) {
632        debug!("MCP tool denied (read-only mode): tool={tool_name}");
633        let result = mcp_tool_error(messages::MCP_TOOL_DENIED_READ_ONLY);
634        if let Some(audit) = ctx.audit.as_ref() {
635            audit.record(tool_name, &args, AuditOutcome::Denied);
636        }
637        return JsonRpcResponse::success(None, result);
638    }
639
640    let result = match tool_name {
641        "list_hosts" => tool_list_hosts(&args, &ctx.config_path),
642        "get_host" => tool_get_host(&args, &ctx.config_path),
643        "run_command" => tool_run_command(&args, &ctx.config_path),
644        "list_containers" => tool_list_containers(&args, &ctx.config_path),
645        "container_action" => tool_container_action(&args, &ctx.config_path),
646        _ => mcp_tool_error(&format!("Unknown tool: {tool_name}")),
647    };
648
649    if let Some(audit) = ctx.audit.as_ref() {
650        let outcome = if result.get("isError").and_then(|v| v.as_bool()) == Some(true) {
651            AuditOutcome::Error
652        } else {
653            AuditOutcome::Allowed
654        };
655        audit.record(tool_name, &args, outcome);
656    }
657
658    JsonRpcResponse::success(None, result)
659}
660
661fn tool_list_hosts(args: &Value, config_path: &Path) -> Value {
662    if let Err(e) = require_config_exists(config_path) {
663        return e;
664    }
665    let config = match SshConfigFile::parse(config_path) {
666        Ok(c) => c,
667        Err(e) => return mcp_tool_error(&format!("Failed to parse SSH config: {e}")),
668    };
669
670    let entries = config.host_entries();
671    let tag_filter = args.get("tag").and_then(|t| t.as_str());
672
673    let hosts: Vec<Value> = entries
674        .iter()
675        .filter(|entry| {
676            // Skip host patterns (already filtered by host_entries, but be safe)
677            if is_host_pattern(&entry.alias) {
678                return false;
679            }
680
681            // Apply tag filter (fuzzy: substring match on tags, provider_tags, provider name)
682            if let Some(tag) = tag_filter {
683                let tag_lower = tag.to_lowercase();
684                let matches_tags = entry
685                    .tags
686                    .iter()
687                    .any(|t| t.to_lowercase().contains(&tag_lower));
688                let matches_provider_tags = entry
689                    .provider_tags
690                    .iter()
691                    .any(|t| t.to_lowercase().contains(&tag_lower));
692                let matches_provider = entry
693                    .provider
694                    .as_ref()
695                    .is_some_and(|p| p.to_lowercase().contains(&tag_lower));
696                if !matches_tags && !matches_provider_tags && !matches_provider {
697                    return false;
698                }
699            }
700
701            true
702        })
703        .map(|entry| {
704            serde_json::json!({
705                "alias": entry.alias,
706                "hostname": entry.hostname,
707                "user": entry.user,
708                "port": entry.port,
709                "tags": entry.tags,
710                "provider": entry.provider,
711                "stale": entry.stale.is_some(),
712            })
713        })
714        .collect();
715
716    let json_str = serde_json::to_string_pretty(&hosts)
717        .expect("serde_json::json! values are always serialisable");
718    mcp_tool_result(&json_str)
719}
720
721fn tool_get_host(args: &Value, config_path: &Path) -> Value {
722    let alias = match args.get("alias").and_then(|a| a.as_str()) {
723        Some(a) if !a.is_empty() => a,
724        _ => return mcp_tool_error("Missing required parameter: alias"),
725    };
726
727    if let Err(e) = require_config_exists(config_path) {
728        return e;
729    }
730    let config = match SshConfigFile::parse(config_path) {
731        Ok(c) => c,
732        Err(e) => return mcp_tool_error(&format!("Failed to parse SSH config: {e}")),
733    };
734
735    let entries = config.host_entries();
736    let entry = entries.iter().find(|e| e.alias == alias);
737
738    match entry {
739        Some(entry) => {
740            let meta: serde_json::Map<String, Value> = entry
741                .provider_meta
742                .iter()
743                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
744                .collect();
745
746            let host = serde_json::json!({
747                "alias": entry.alias,
748                "hostname": entry.hostname,
749                "user": entry.user,
750                "port": entry.port,
751                "identity_file": entry.identity_file,
752                "proxy_jump": entry.proxy_jump,
753                "tags": entry.tags,
754                "provider_tags": entry.provider_tags,
755                "provider": entry.provider,
756                "provider_meta": meta,
757                "askpass": entry.askpass,
758                "tunnel_count": entry.tunnel_count,
759                "stale": entry.stale.is_some(),
760            });
761
762            let json_str = serde_json::to_string_pretty(&host)
763                .expect("serde_json::json! values are always serialisable");
764            mcp_tool_result(&json_str)
765        }
766        None => mcp_tool_error(&format!("Host not found: {alias}")),
767    }
768}
769
770fn tool_run_command(args: &Value, config_path: &Path) -> Value {
771    let alias = match args.get("alias").and_then(|a| a.as_str()) {
772        Some(a) if !a.is_empty() => a,
773        _ => return mcp_tool_error("Missing required parameter: alias"),
774    };
775    let command = match args.get("command").and_then(|c| c.as_str()) {
776        Some(c) if !c.is_empty() => c,
777        _ => return mcp_tool_error("Missing required parameter: command"),
778    };
779    // Clamp to the schema-advertised maximum. A misbehaving or compromised
780    // client could otherwise hold the single-threaded MCP server for hours
781    // by sending an unreasonably large timeout.
782    let timeout_secs = args
783        .get("timeout")
784        .and_then(|t| t.as_u64())
785        .unwrap_or(30)
786        .clamp(1, 300);
787
788    if let Err(e) = verify_alias_exists(alias, config_path) {
789        return e;
790    }
791
792    // Do not log the command body. It can carry secrets (passwords, tokens)
793    // passed as shell arguments. The audit log redacts the same field; the
794    // application log must not be a side channel that leaks them.
795    debug!("MCP tool: run_command alias={alias}");
796    match ssh_exec(alias, config_path, command, timeout_secs) {
797        Ok((exit_code, stdout, stderr)) => {
798            if exit_code != 0 {
799                error!("[external] MCP ssh_exec failed: alias={alias} exit={exit_code}");
800            }
801            let result = serde_json::json!({
802                "exit_code": exit_code,
803                "stdout": stdout,
804                "stderr": stderr
805            });
806            let json_str = serde_json::to_string_pretty(&result)
807                .expect("serde_json::json! values are always serialisable");
808            mcp_tool_result(&json_str)
809        }
810        Err(e) => e,
811    }
812}
813
814fn tool_list_containers(args: &Value, config_path: &Path) -> Value {
815    let alias = match args.get("alias").and_then(|a| a.as_str()) {
816        Some(a) if !a.is_empty() => a,
817        _ => return mcp_tool_error("Missing required parameter: alias"),
818    };
819
820    if let Err(e) = verify_alias_exists(alias, config_path) {
821        return e;
822    }
823
824    // Build the combined detection + listing command
825    let command = crate::containers::container_list_command(None);
826
827    let (exit_code, stdout, stderr) = match ssh_exec(alias, config_path, &command, 30) {
828        Ok(r) => r,
829        Err(e) => return e,
830    };
831
832    if exit_code != 0 {
833        return mcp_tool_error(&format!("SSH command failed: {}", stderr.trim()));
834    }
835
836    match crate::containers::parse_container_output(&stdout, None) {
837        Ok((runtime, containers)) => {
838            let containers_json: Vec<Value> = containers
839                .iter()
840                .map(|c| {
841                    serde_json::json!({
842                        "id": c.id,
843                        "name": c.names,
844                        "image": c.image,
845                        "state": c.state,
846                        "status": c.status,
847                        "ports": c.ports,
848                    })
849                })
850                .collect();
851            let result = serde_json::json!({
852                "runtime": runtime.as_str(),
853                "containers": containers_json,
854            });
855            let json_str = serde_json::to_string_pretty(&result)
856                .expect("serde_json::json! values are always serialisable");
857            mcp_tool_result(&json_str)
858        }
859        Err(e) => mcp_tool_error(&e),
860    }
861}
862
863fn tool_container_action(args: &Value, config_path: &Path) -> Value {
864    let alias = match args.get("alias").and_then(|a| a.as_str()) {
865        Some(a) if !a.is_empty() => a,
866        _ => return mcp_tool_error("Missing required parameter: alias"),
867    };
868    let container_id = match args.get("container_id").and_then(|c| c.as_str()) {
869        Some(c) if !c.is_empty() => c,
870        _ => return mcp_tool_error("Missing required parameter: container_id"),
871    };
872    let action_str = match args.get("action").and_then(|a| a.as_str()) {
873        Some(a) => a,
874        None => return mcp_tool_error("Missing required parameter: action"),
875    };
876
877    // Validate container ID (injection prevention)
878    if let Err(e) = crate::containers::validate_container_id(container_id) {
879        return mcp_tool_error(&e);
880    }
881
882    let action = match action_str {
883        "start" => crate::containers::ContainerAction::Start,
884        "stop" => crate::containers::ContainerAction::Stop,
885        "restart" => crate::containers::ContainerAction::Restart,
886        _ => {
887            return mcp_tool_error(&format!(
888                "Invalid action: {action_str}. Must be start, stop or restart"
889            ));
890        }
891    };
892
893    if let Err(e) = verify_alias_exists(alias, config_path) {
894        return e;
895    }
896
897    // First detect runtime
898    let detect_cmd = crate::containers::container_list_command(None);
899
900    let (detect_exit, detect_stdout, detect_stderr) =
901        match ssh_exec(alias, config_path, &detect_cmd, 30) {
902            Ok(r) => r,
903            Err(e) => return e,
904        };
905
906    if detect_exit != 0 {
907        return mcp_tool_error(&format!(
908            "Failed to detect container runtime: {}",
909            detect_stderr.trim()
910        ));
911    }
912
913    let runtime = match crate::containers::parse_container_output(&detect_stdout, None) {
914        Ok((rt, _)) => rt,
915        Err(e) => return mcp_tool_error(&format!("Failed to detect container runtime: {e}")),
916    };
917
918    let action_command = crate::containers::container_action_command(runtime, action, container_id);
919
920    let (action_exit, _action_stdout, action_stderr) =
921        match ssh_exec(alias, config_path, &action_command, 30) {
922            Ok(r) => r,
923            Err(e) => return e,
924        };
925
926    if action_exit == 0 {
927        let past = match action_str {
928            "start" => "started",
929            "stop" => "stopped",
930            "restart" => "restarted",
931            other => other,
932        };
933        let result = serde_json::json!({
934            "success": true,
935            "message": format!("Container {container_id} {past}"),
936        });
937        let json_str = serde_json::to_string_pretty(&result)
938            .expect("serde_json::json! values are always serialisable");
939        mcp_tool_result(&json_str)
940    } else {
941        mcp_tool_error(&format!(
942            "Container action failed: {}",
943            action_stderr.trim()
944        ))
945    }
946}
947
948/// Run the MCP server, reading JSON-RPC requests from stdin and writing
949/// responses to stdout. Blocks until stdin is closed.
950pub fn run(config_path: &Path, options: McpOptions) -> anyhow::Result<()> {
951    info!(
952        "MCP server starting (read_only={}, audit_log={})",
953        options.read_only,
954        options
955            .audit_log_path
956            .as_ref()
957            .map(|p| p.display().to_string())
958            .unwrap_or_else(|| "disabled".to_string())
959    );
960    let ctx = McpContext::new(config_path.to_path_buf(), options);
961
962    let stdin = std::io::stdin();
963    let stdout = std::io::stdout();
964    let reader = stdin.lock();
965    let mut writer = stdout.lock();
966
967    for line in reader.lines() {
968        let line = match line {
969            Ok(l) => l,
970            Err(_) => break,
971        };
972        let trimmed = line.trim();
973        if trimmed.is_empty() {
974            continue;
975        }
976
977        let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
978            Ok(r) => r,
979            Err(_) => {
980                let resp = JsonRpcResponse::error(None, -32700, "Parse error".to_string());
981                let json = serde_json::to_string(&resp)?;
982                writeln!(writer, "{json}")?;
983                writer.flush()?;
984                continue;
985            }
986        };
987
988        // Notifications (no id) don't get responses
989        if request.id.is_none() {
990            debug!("MCP notification: {}", request.method);
991            continue;
992        }
993
994        debug!("MCP request: method={}", request.method);
995        let mut response = dispatch(&request.method, request.params, &ctx);
996        debug!(
997            "MCP response: method={} success={}",
998            request.method,
999            response.error.is_none()
1000        );
1001        response.id = request.id;
1002
1003        let json = serde_json::to_string(&response)?;
1004        writeln!(writer, "{json}")?;
1005        writer.flush()?;
1006    }
1007
1008    Ok(())
1009}
1010
1011#[cfg(test)]
1012#[path = "mcp_tests.rs"]
1013mod tests;