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
14const READ_ONLY_TOOLS: &[&str] = &["list_hosts", "get_host", "list_containers"];
17
18#[derive(Debug, Clone, Default)]
20pub struct McpOptions {
21 pub read_only: bool,
24 pub audit_log_path: Option<PathBuf>,
26}
27
28pub 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 fn is_tool_allowed(&self, tool: &str) -> bool {
69 !self.options.read_only || READ_ONLY_TOOLS.contains(&tool)
70 }
71}
72
73pub 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 }
90 }
91 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 #[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 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;
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
178fn 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 redacted = Value::String("<redacted: non-object args>".to_string());
204 }
205 }
206 redacted
207}
208
209fn 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
229fn 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
245pub 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
250fn 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#[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#[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#[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
313fn mcp_tool_result(text: &str) -> Value {
315 serde_json::json!({
316 "content": [{"type": "text", "text": text}]
317 })
318}
319
320fn mcp_tool_error(text: &str) -> Value {
322 serde_json::json!({
323 "content": [{"type": "text", "text": text}],
324 "isError": true
325 })
326}
327
328fn 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
340fn 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
358fn 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 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
426pub(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
476fn 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 if is_host_pattern(&entry.alias) {
688 return false;
689 }
690
691 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 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 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 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 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 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
966pub 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 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;