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