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            if !parent.as_os_str().is_empty() {
85                std::fs::create_dir_all(parent)?;
86                // Note: we do not chmod the parent. It may be a user-chosen
87                // location (e.g. /tmp) that we have no business locking down.
88                // The log file itself is set to 0o600 below.
89            }
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            if meta.file_type().is_symlink() {
96                return Err(std::io::Error::new(
97                    std::io::ErrorKind::PermissionDenied,
98                    "audit log path is a symlink; refusing to open",
99                ));
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 (`~/.purple/mcp-audit.log`).
246pub fn default_audit_log_path(paths: Option<&crate::runtime::env::Paths>) -> Option<PathBuf> {
247    audit_log_path_from_home(paths.map(|p| p.home().to_path_buf()))
248}
249
250/// Helper extracted so the `home_dir = None` branch is unit-testable.
251/// Production callers use `default_audit_log_path()`.
252fn audit_log_path_from_home(home: Option<PathBuf>) -> Option<PathBuf> {
253    match home {
254        Some(h) => Some(h.join(".purple").join("mcp-audit.log")),
255        None => {
256            warn!("[purple] {}", messages::MCP_AUDIT_HOME_DIR_UNAVAILABLE);
257            None
258        }
259    }
260}
261
262/// A JSON-RPC 2.0 request.
263#[derive(Debug, Deserialize)]
264pub struct JsonRpcRequest {
265    #[allow(dead_code)]
266    pub jsonrpc: String,
267    #[serde(default)]
268    pub id: Option<Value>,
269    pub method: String,
270    #[serde(default)]
271    pub params: Option<Value>,
272}
273
274/// A JSON-RPC 2.0 response.
275#[derive(Debug, Serialize)]
276pub struct JsonRpcResponse {
277    pub jsonrpc: String,
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub id: Option<Value>,
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub result: Option<Value>,
282    #[serde(skip_serializing_if = "Option::is_none")]
283    pub error: Option<JsonRpcError>,
284}
285
286/// A JSON-RPC 2.0 error object.
287#[derive(Debug, Serialize)]
288pub struct JsonRpcError {
289    pub code: i64,
290    pub message: String,
291}
292
293impl JsonRpcResponse {
294    fn success(id: Option<Value>, result: Value) -> Self {
295        Self {
296            jsonrpc: "2.0".to_string(),
297            id,
298            result: Some(result),
299            error: None,
300        }
301    }
302
303    fn error(id: Option<Value>, code: i64, message: String) -> Self {
304        Self {
305            jsonrpc: "2.0".to_string(),
306            id,
307            result: None,
308            error: Some(JsonRpcError { code, message }),
309        }
310    }
311}
312
313/// Helper to build an MCP tool result (success).
314fn mcp_tool_result(text: &str) -> Value {
315    serde_json::json!({
316        "content": [{"type": "text", "text": text}]
317    })
318}
319
320/// Helper to build an MCP tool error result.
321fn mcp_tool_error(text: &str) -> Value {
322    serde_json::json!({
323        "content": [{"type": "text", "text": text}],
324        "isError": true
325    })
326}
327
328/// Surface a missing config file as an explicit MCP error rather than letting
329/// the parser silently produce an empty config (the failure mode that caused
330/// the .mcpb-with-unexpanded-${HOME} bug to return `[]` instead of erroring).
331fn require_config_exists(config_path: &Path) -> Result<(), Value> {
332    if !config_path.exists() {
333        return Err(mcp_tool_error(&messages::mcp_config_file_not_found(
334            &config_path.display(),
335        )));
336    }
337    Ok(())
338}
339
340/// Verify that an alias exists in the SSH config. Returns error Value if not found.
341fn verify_alias_exists(
342    alias: &str,
343    config_path: &Path,
344    env: &crate::runtime::env::Env,
345) -> Result<(), Value> {
346    require_config_exists(config_path)?;
347    let config = match SshConfigFile::parse_with_env(config_path, env) {
348        Ok(c) => c,
349        Err(e) => return Err(mcp_tool_error(&format!("Failed to parse SSH config: {e}"))),
350    };
351    let exists = config.host_entries().iter().any(|h| h.alias == alias);
352    if !exists {
353        return Err(mcp_tool_error(&format!("Host not found: {alias}")));
354    }
355    Ok(())
356}
357
358/// Run an SSH command with a timeout. Returns (exit_code, stdout, stderr).
359fn ssh_exec(
360    alias: &str,
361    config_path: &Path,
362    command: &str,
363    timeout_secs: u64,
364) -> Result<(i32, String, String), Value> {
365    let config_str = config_path.to_string_lossy();
366    let child = match std::process::Command::new("ssh")
367        .args([
368            "-F",
369            &config_str,
370            "-o",
371            "ConnectTimeout=10",
372            "-o",
373            "BatchMode=yes",
374            "--",
375            alias,
376            command,
377        ])
378        .stdin(std::process::Stdio::null())
379        .stdout(std::process::Stdio::piped())
380        .stderr(std::process::Stdio::piped())
381        .spawn()
382    {
383        Ok(c) => c,
384        Err(e) => return Err(mcp_tool_error(&format!("Failed to spawn ssh: {e}"))),
385    };
386
387    // Wait with timeout via mpsc instead of busy-polling. The waiter thread
388    // owns the child and reads its stdout/stderr to completion via
389    // `wait_with_output`. The main thread blocks on `recv_timeout`. On
390    // timeout we kill the orphan process by PID via `kill(1)` since we no
391    // longer hold a `Child` handle here. POSIX-only path is acceptable: the
392    // project runs on macOS and Linux.
393    let pid = child.id();
394    let (tx, rx) = std::sync::mpsc::channel();
395    std::thread::spawn(move || {
396        let _ = tx.send(child.wait_with_output());
397    });
398
399    match rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) {
400        Ok(Ok(out)) => {
401            let exit = out.status.code().unwrap_or(-1);
402            let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
403            let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
404            Ok((exit, stdout, stderr))
405        }
406        Ok(Err(e)) => Err(mcp_tool_error(&format!("Failed to wait for ssh: {e}"))),
407        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
408            #[cfg(unix)]
409            {
410                let _ = std::process::Command::new("kill")
411                    .arg("-TERM")
412                    .arg(pid.to_string())
413                    .status();
414            }
415            warn!("[external] MCP SSH command timed out after {timeout_secs}s (pid {pid})");
416            Err(mcp_tool_error(&format!(
417                "SSH command timed out after {timeout_secs} seconds"
418            )))
419        }
420        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
421            Err(mcp_tool_error("ssh waiter thread disconnected"))
422        }
423    }
424}
425
426/// Dispatch a JSON-RPC method to the appropriate handler.
427pub(crate) fn dispatch(method: &str, params: Option<Value>, ctx: &McpContext) -> JsonRpcResponse {
428    match method {
429        "initialize" => handle_initialize(),
430        "tools/list" => handle_tools_list(ctx),
431        "tools/call" => handle_tools_call(params, ctx),
432        _ => JsonRpcResponse::error(None, -32601, format!("Method not found: {method}")),
433    }
434}
435
436fn handle_initialize() -> JsonRpcResponse {
437    JsonRpcResponse::success(
438        None,
439        serde_json::json!({
440            "protocolVersion": "2024-11-05",
441            "capabilities": {
442                "tools": {}
443            },
444            "serverInfo": {
445                "name": "purple",
446                "version": env!("CARGO_PKG_VERSION")
447            }
448        }),
449    )
450}
451
452fn handle_tools_list(ctx: &McpContext) -> JsonRpcResponse {
453    let all_tools = all_tools_descriptor();
454    let tools = if ctx.options.read_only {
455        let filtered: Vec<Value> = all_tools
456            .as_array()
457            .map(|arr| {
458                arr.iter()
459                    .filter(|t| {
460                        t.get("name")
461                            .and_then(|n| n.as_str())
462                            .map(|n| READ_ONLY_TOOLS.contains(&n))
463                            .unwrap_or(false)
464                    })
465                    .cloned()
466                    .collect()
467            })
468            .unwrap_or_default();
469        serde_json::json!({ "tools": filtered })
470    } else {
471        serde_json::json!({ "tools": all_tools })
472    };
473    JsonRpcResponse::success(None, tools)
474}
475
476/// Static descriptor cached on first call. Building the JSON once instead of
477/// per-request avoids ~90 lines of `serde_json::json!` allocation on every
478/// `tools/list`. Returns a borrow; callers clone if they need ownership.
479fn all_tools_descriptor() -> &'static Value {
480    static DESCRIPTOR: OnceLock<Value> = OnceLock::new();
481    DESCRIPTOR.get_or_init(build_all_tools_descriptor)
482}
483
484fn build_all_tools_descriptor() -> Value {
485    serde_json::json!([
486        {
487            "name": "list_hosts",
488            "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.",
489            "annotations": {
490                "title": "List SSH hosts",
491                "readOnlyHint": true,
492                "destructiveHint": false,
493                "idempotentHint": true,
494                "openWorldHint": false
495            },
496            "inputSchema": {
497                "type": "object",
498                "properties": {
499                    "tag": {
500                        "type": "string",
501                        "description": "Filter hosts by tag (fuzzy match against tags, provider_tags and provider name)"
502                    }
503                }
504            }
505        },
506        {
507            "name": "get_host",
508            "description": "Get detailed information for a single SSH host including identity file, proxy jump, provider metadata, password source and tunnel count.",
509            "annotations": {
510                "title": "Get SSH host details",
511                "readOnlyHint": true,
512                "destructiveHint": false,
513                "idempotentHint": true,
514                "openWorldHint": false
515            },
516            "inputSchema": {
517                "type": "object",
518                "properties": {
519                    "alias": {
520                        "type": "string",
521                        "description": "The host alias to look up"
522                    }
523                },
524                "required": ["alias"]
525            }
526        },
527        {
528            "name": "run_command",
529            "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.",
530            "annotations": {
531                "title": "Run shell command on SSH host",
532                "readOnlyHint": false,
533                "destructiveHint": true,
534                "idempotentHint": false,
535                "openWorldHint": true
536            },
537            "inputSchema": {
538                "type": "object",
539                "properties": {
540                    "alias": {
541                        "type": "string",
542                        "description": "The host alias to connect to"
543                    },
544                    "command": {
545                        "type": "string",
546                        "description": "The command to execute"
547                    },
548                    "timeout": {
549                        "type": "integer",
550                        "description": "Timeout in seconds (default 30)",
551                        "default": 30,
552                        "minimum": 1,
553                        "maximum": 300
554                    }
555                },
556                "required": ["alias", "command"]
557            }
558        },
559        {
560            "name": "list_containers",
561            "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.",
562            "annotations": {
563                "title": "List containers on SSH host",
564                "readOnlyHint": true,
565                "destructiveHint": false,
566                "idempotentHint": true,
567                "openWorldHint": false
568            },
569            "inputSchema": {
570                "type": "object",
571                "properties": {
572                    "alias": {
573                        "type": "string",
574                        "description": "The host alias to list containers for"
575                    }
576                },
577                "required": ["alias"]
578            }
579        },
580        {
581            "name": "container_action",
582            "description": "Start, stop or restart a Docker or Podman container on a remote host via SSH. Auto-detects the container runtime.",
583            "annotations": {
584                "title": "Start, stop or restart container",
585                "readOnlyHint": false,
586                "destructiveHint": true,
587                "idempotentHint": false,
588                "openWorldHint": false
589            },
590            "inputSchema": {
591                "type": "object",
592                "properties": {
593                    "alias": {
594                        "type": "string",
595                        "description": "The host alias"
596                    },
597                    "container_id": {
598                        "type": "string",
599                        "description": "The container ID or name"
600                    },
601                    "action": {
602                        "type": "string",
603                        "description": "The action to perform",
604                        "enum": ["start", "stop", "restart"]
605                    }
606                },
607                "required": ["alias", "container_id", "action"]
608            }
609        }
610    ])
611}
612
613fn handle_tools_call(params: Option<Value>, ctx: &McpContext) -> JsonRpcResponse {
614    let params = match params {
615        Some(p) => p,
616        None => {
617            return JsonRpcResponse::error(
618                None,
619                -32602,
620                "Invalid params: missing params object".to_string(),
621            );
622        }
623    };
624
625    let tool_name = match params.get("name").and_then(|n| n.as_str()) {
626        Some(n) => n,
627        None => {
628            return JsonRpcResponse::error(
629                None,
630                -32602,
631                "Invalid params: missing tool name".to_string(),
632            );
633        }
634    };
635
636    let args = params
637        .get("arguments")
638        .cloned()
639        .unwrap_or(serde_json::json!({}));
640
641    if !ctx.is_tool_allowed(tool_name) {
642        debug!("MCP tool denied (read-only mode): tool={tool_name}");
643        let result = mcp_tool_error(messages::MCP_TOOL_DENIED_READ_ONLY);
644        if let Some(audit) = ctx.audit.as_ref() {
645            audit.record(tool_name, &args, AuditOutcome::Denied);
646        }
647        return JsonRpcResponse::success(None, result);
648    }
649
650    let result = match tool_name {
651        "list_hosts" => tool_list_hosts(&args, &ctx.config_path, &ctx.env),
652        "get_host" => tool_get_host(&args, &ctx.config_path, &ctx.env),
653        "run_command" => tool_run_command(&args, &ctx.config_path, &ctx.env),
654        "list_containers" => tool_list_containers(&args, &ctx.config_path, &ctx.env),
655        "container_action" => tool_container_action(&args, &ctx.config_path, &ctx.env),
656        _ => mcp_tool_error(&format!("Unknown tool: {tool_name}")),
657    };
658
659    if let Some(audit) = ctx.audit.as_ref() {
660        let outcome = if result.get("isError").and_then(|v| v.as_bool()) == Some(true) {
661            AuditOutcome::Error
662        } else {
663            AuditOutcome::Allowed
664        };
665        audit.record(tool_name, &args, outcome);
666    }
667
668    JsonRpcResponse::success(None, result)
669}
670
671fn tool_list_hosts(args: &Value, config_path: &Path, env: &crate::runtime::env::Env) -> Value {
672    if let Err(e) = require_config_exists(config_path) {
673        return e;
674    }
675    let config = match SshConfigFile::parse_with_env(config_path, env) {
676        Ok(c) => c,
677        Err(e) => return mcp_tool_error(&format!("Failed to parse SSH config: {e}")),
678    };
679
680    let entries = config.host_entries();
681    let tag_filter = args.get("tag").and_then(|t| t.as_str());
682
683    let hosts: Vec<Value> = entries
684        .iter()
685        .filter(|entry| {
686            // Skip host patterns (already filtered by host_entries, but be safe)
687            if is_host_pattern(&entry.alias) {
688                return false;
689            }
690
691            // Apply tag filter (fuzzy: substring match on tags, provider_tags, provider name)
692            if let Some(tag) = tag_filter {
693                let tag_lower = tag.to_lowercase();
694                let matches_tags = entry
695                    .tags
696                    .iter()
697                    .any(|t| t.to_lowercase().contains(&tag_lower));
698                let matches_provider_tags = entry
699                    .provider_tags
700                    .iter()
701                    .any(|t| t.to_lowercase().contains(&tag_lower));
702                let matches_provider = entry
703                    .provider
704                    .as_ref()
705                    .is_some_and(|p| p.to_lowercase().contains(&tag_lower));
706                if !matches_tags && !matches_provider_tags && !matches_provider {
707                    return false;
708                }
709            }
710
711            true
712        })
713        .map(|entry| {
714            serde_json::json!({
715                "alias": entry.alias,
716                "hostname": entry.hostname,
717                "user": entry.user,
718                "port": entry.port,
719                "tags": entry.tags,
720                "provider": entry.provider,
721                "stale": entry.stale.is_some(),
722            })
723        })
724        .collect();
725
726    let json_str = serde_json::to_string_pretty(&hosts)
727        .expect("serde_json::json! values are always serialisable");
728    mcp_tool_result(&json_str)
729}
730
731fn tool_get_host(args: &Value, config_path: &Path, env: &crate::runtime::env::Env) -> Value {
732    let alias = match args.get("alias").and_then(|a| a.as_str()) {
733        Some(a) if !a.is_empty() => a,
734        _ => return mcp_tool_error("Missing required parameter: alias"),
735    };
736
737    if let Err(e) = require_config_exists(config_path) {
738        return e;
739    }
740    let config = match SshConfigFile::parse_with_env(config_path, env) {
741        Ok(c) => c,
742        Err(e) => return mcp_tool_error(&format!("Failed to parse SSH config: {e}")),
743    };
744
745    let entries = config.host_entries();
746    let entry = entries.iter().find(|e| e.alias == alias);
747
748    match entry {
749        Some(entry) => {
750            let meta: serde_json::Map<String, Value> = entry
751                .provider_meta
752                .iter()
753                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
754                .collect();
755
756            let host = serde_json::json!({
757                "alias": entry.alias,
758                "hostname": entry.hostname,
759                "user": entry.user,
760                "port": entry.port,
761                "identity_file": entry.identity_file,
762                "proxy_jump": entry.proxy_jump,
763                "tags": entry.tags,
764                "provider_tags": entry.provider_tags,
765                "provider": entry.provider,
766                "provider_meta": meta,
767                "askpass": entry.askpass,
768                "tunnel_count": entry.tunnel_count,
769                "stale": entry.stale.is_some(),
770            });
771
772            let json_str = serde_json::to_string_pretty(&host)
773                .expect("serde_json::json! values are always serialisable");
774            mcp_tool_result(&json_str)
775        }
776        None => mcp_tool_error(&format!("Host not found: {alias}")),
777    }
778}
779
780fn tool_run_command(args: &Value, config_path: &Path, env: &crate::runtime::env::Env) -> Value {
781    let alias = match args.get("alias").and_then(|a| a.as_str()) {
782        Some(a) if !a.is_empty() => a,
783        _ => return mcp_tool_error("Missing required parameter: alias"),
784    };
785    let command = match args.get("command").and_then(|c| c.as_str()) {
786        Some(c) if !c.is_empty() => c,
787        _ => return mcp_tool_error("Missing required parameter: command"),
788    };
789    // Clamp to the schema-advertised maximum. A misbehaving or compromised
790    // client could otherwise hold the single-threaded MCP server for hours
791    // by sending an unreasonably large timeout.
792    let timeout_secs = args
793        .get("timeout")
794        .and_then(|t| t.as_u64())
795        .unwrap_or(30)
796        .clamp(1, 300);
797
798    if let Err(e) = verify_alias_exists(alias, config_path, env) {
799        return e;
800    }
801
802    // Do not log the command body. It can carry secrets (passwords, tokens)
803    // passed as shell arguments. The audit log redacts the same field; the
804    // application log must not be a side channel that leaks them.
805    debug!("MCP tool: run_command alias={alias}");
806    match ssh_exec(alias, config_path, command, timeout_secs) {
807        Ok((exit_code, stdout, stderr)) => {
808            if exit_code != 0 {
809                error!("[external] MCP ssh_exec failed: alias={alias} exit={exit_code}");
810            }
811            let result = serde_json::json!({
812                "exit_code": exit_code,
813                "stdout": stdout,
814                "stderr": stderr
815            });
816            let json_str = serde_json::to_string_pretty(&result)
817                .expect("serde_json::json! values are always serialisable");
818            mcp_tool_result(&json_str)
819        }
820        Err(e) => e,
821    }
822}
823
824fn tool_list_containers(args: &Value, config_path: &Path, env: &crate::runtime::env::Env) -> Value {
825    let alias = match args.get("alias").and_then(|a| a.as_str()) {
826        Some(a) if !a.is_empty() => a,
827        _ => return mcp_tool_error("Missing required parameter: alias"),
828    };
829
830    if let Err(e) = verify_alias_exists(alias, config_path, env) {
831        return e;
832    }
833
834    // Build the combined detection + listing command
835    let command = crate::containers::container_list_command(None);
836
837    let (exit_code, stdout, stderr) = match ssh_exec(alias, config_path, &command, 30) {
838        Ok(r) => r,
839        Err(e) => return e,
840    };
841
842    if exit_code != 0 {
843        return mcp_tool_error(&format!("SSH command failed: {}", stderr.trim()));
844    }
845
846    match crate::containers::parse_container_output(&stdout, None) {
847        Ok(listing) => {
848            let containers_json: Vec<Value> = listing
849                .containers
850                .iter()
851                .map(|c| {
852                    serde_json::json!({
853                        "id": c.id,
854                        "name": c.names,
855                        "image": c.image,
856                        "state": c.state,
857                        "status": c.status,
858                        "ports": c.ports,
859                    })
860                })
861                .collect();
862            let mut result = serde_json::json!({
863                "runtime": listing.runtime.as_str(),
864                "containers": containers_json,
865            });
866            if let Some(v) = listing.engine_version {
867                result["engine_version"] = serde_json::Value::String(v);
868            }
869            let json_str = serde_json::to_string_pretty(&result)
870                .expect("serde_json::json! values are always serialisable");
871            mcp_tool_result(&json_str)
872        }
873        Err(e) => mcp_tool_error(&e),
874    }
875}
876
877fn tool_container_action(
878    args: &Value,
879    config_path: &Path,
880    env: &crate::runtime::env::Env,
881) -> Value {
882    let alias = match args.get("alias").and_then(|a| a.as_str()) {
883        Some(a) if !a.is_empty() => a,
884        _ => return mcp_tool_error("Missing required parameter: alias"),
885    };
886    let container_id = match args.get("container_id").and_then(|c| c.as_str()) {
887        Some(c) if !c.is_empty() => c,
888        _ => return mcp_tool_error("Missing required parameter: container_id"),
889    };
890    let action_str = match args.get("action").and_then(|a| a.as_str()) {
891        Some(a) => a,
892        None => return mcp_tool_error("Missing required parameter: action"),
893    };
894
895    // Validate container ID (injection prevention)
896    if let Err(e) = crate::containers::validate_container_id(container_id) {
897        return mcp_tool_error(&e);
898    }
899
900    let action = match action_str {
901        "start" => crate::containers::ContainerAction::Start,
902        "stop" => crate::containers::ContainerAction::Stop,
903        "restart" => crate::containers::ContainerAction::Restart,
904        _ => {
905            return mcp_tool_error(&format!(
906                "Invalid action: {action_str}. Must be start, stop or restart"
907            ));
908        }
909    };
910
911    if let Err(e) = verify_alias_exists(alias, config_path, env) {
912        return e;
913    }
914
915    // First detect runtime
916    let detect_cmd = crate::containers::container_list_command(None);
917
918    let (detect_exit, detect_stdout, detect_stderr) =
919        match ssh_exec(alias, config_path, &detect_cmd, 30) {
920            Ok(r) => r,
921            Err(e) => return e,
922        };
923
924    if detect_exit != 0 {
925        return mcp_tool_error(&format!(
926            "Failed to detect container runtime: {}",
927            detect_stderr.trim()
928        ));
929    }
930
931    let runtime = match crate::containers::parse_container_output(&detect_stdout, None) {
932        Ok(listing) => listing.runtime,
933        Err(e) => return mcp_tool_error(&format!("Failed to detect container runtime: {e}")),
934    };
935
936    let action_command = crate::containers::container_action_command(runtime, action, container_id);
937
938    let (action_exit, _action_stdout, action_stderr) =
939        match ssh_exec(alias, config_path, &action_command, 30) {
940            Ok(r) => r,
941            Err(e) => return e,
942        };
943
944    if action_exit == 0 {
945        let past = match action_str {
946            "start" => "started",
947            "stop" => "stopped",
948            "restart" => "restarted",
949            other => other,
950        };
951        let result = serde_json::json!({
952            "success": true,
953            "message": format!("Container {container_id} {past}"),
954        });
955        let json_str = serde_json::to_string_pretty(&result)
956            .expect("serde_json::json! values are always serialisable");
957        mcp_tool_result(&json_str)
958    } else {
959        mcp_tool_error(&format!(
960            "Container action failed: {}",
961            action_stderr.trim()
962        ))
963    }
964}
965
966/// Run the MCP server, reading JSON-RPC requests from stdin and writing
967/// responses to stdout. Blocks until stdin is closed.
968pub fn run(
969    config_path: &Path,
970    options: McpOptions,
971    env: std::sync::Arc<crate::runtime::env::Env>,
972) -> anyhow::Result<()> {
973    info!(
974        "MCP server starting (read_only={}, audit_log={})",
975        options.read_only,
976        options
977            .audit_log_path
978            .as_ref()
979            .map(|p| p.display().to_string())
980            .unwrap_or_else(|| "disabled".to_string())
981    );
982    let ctx = McpContext::new(config_path.to_path_buf(), options, env);
983
984    let stdin = std::io::stdin();
985    let stdout = std::io::stdout();
986    let reader = stdin.lock();
987    let mut writer = stdout.lock();
988
989    for line in reader.lines() {
990        let line = match line {
991            Ok(l) => l,
992            Err(_) => break,
993        };
994        let trimmed = line.trim();
995        if trimmed.is_empty() {
996            continue;
997        }
998
999        let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
1000            Ok(r) => r,
1001            Err(_) => {
1002                let resp = JsonRpcResponse::error(None, -32700, "Parse error".to_string());
1003                let json = serde_json::to_string(&resp)?;
1004                writeln!(writer, "{json}")?;
1005                writer.flush()?;
1006                continue;
1007            }
1008        };
1009
1010        // Notifications (no id) don't get responses
1011        if request.id.is_none() {
1012            debug!("MCP notification: {}", request.method);
1013            continue;
1014        }
1015
1016        debug!("MCP request: method={}", request.method);
1017        let mut response = dispatch(&request.method, request.params, &ctx);
1018        debug!(
1019            "MCP response: method={} success={}",
1020            request.method,
1021            response.error.is_none()
1022        );
1023        response.id = request.id;
1024
1025        let json = serde_json::to_string(&response)?;
1026        writeln!(writer, "{json}")?;
1027        writer.flush()?;
1028    }
1029
1030    Ok(())
1031}
1032
1033#[cfg(test)]
1034#[path = "mcp_tests.rs"]
1035mod tests;