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}
39
40impl McpContext {
41 pub fn new(config_path: PathBuf, options: McpOptions) -> Self {
42 let audit = options
43 .audit_log_path
44 .as_deref()
45 .and_then(|path| match AuditLog::open(path) {
46 Ok(log) => Some(log),
47 Err(e) => {
48 let body = messages::mcp_audit_init_failed(&path.display(), &e);
49 eprintln!("{body}");
50 warn!("[purple] {body}");
51 None
52 }
53 });
54 Self {
55 config_path,
56 options,
57 audit,
58 }
59 }
60
61 fn is_tool_allowed(&self, tool: &str) -> bool {
63 !self.options.read_only || READ_ONLY_TOOLS.contains(&tool)
64 }
65}
66
67pub struct AuditLog {
72 file: Mutex<File>,
73}
74
75impl AuditLog {
76 pub fn open(path: &Path) -> std::io::Result<Self> {
77 if let Some(parent) = path.parent() {
78 if !parent.as_os_str().is_empty() {
79 std::fs::create_dir_all(parent)?;
80 }
84 }
85 if let Ok(meta) = std::fs::symlink_metadata(path) {
89 if meta.file_type().is_symlink() {
90 return Err(std::io::Error::new(
91 std::io::ErrorKind::PermissionDenied,
92 "audit log path is a symlink; refusing to open",
93 ));
94 }
95 }
96 let file = OpenOptions::new().create(true).append(true).open(path)?;
97 #[cfg(unix)]
101 {
102 use std::os::unix::fs::PermissionsExt;
103 let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
104 }
105 Ok(Self {
106 file: Mutex::new(file),
107 })
108 }
109
110 pub fn record(&self, tool: &str, args: &Value, outcome: AuditOutcome) {
120 let entry = serde_json::json!({
121 "ts": iso8601_now(),
122 "tool": tool,
123 "args": redact_args_for_audit(tool, args),
124 "outcome": outcome.label(),
125 "reason": outcome.reason(),
126 });
127 let line = match serde_json::to_string(&entry) {
128 Ok(s) => s,
129 Err(e) => {
130 warn!("[purple] {}", messages::mcp_audit_write_failed(&e));
131 return;
133 }
134 };
135 let mut guard = match self.file.lock() {
136 Ok(g) => g,
137 Err(poisoned) => poisoned.into_inner(),
138 };
139 if let Err(e) = writeln!(*guard, "{line}") {
140 warn!("[purple] {}", messages::mcp_audit_write_failed(&e));
141 return;
142 }
143 if let Err(e) = guard.flush() {
144 warn!("[purple] {}", messages::mcp_audit_write_failed(&e));
145 }
146 }
147}
148
149#[derive(Debug, Clone, Copy)]
150pub enum AuditOutcome {
151 Allowed,
152 Denied,
153 Error,
154}
155
156impl AuditOutcome {
157 fn label(self) -> &'static str {
158 match self {
159 AuditOutcome::Allowed => "allowed",
160 AuditOutcome::Denied => "denied",
161 AuditOutcome::Error => "error",
162 }
163 }
164 fn reason(self) -> Option<&'static str> {
165 match self {
166 AuditOutcome::Denied => Some("read-only mode"),
167 _ => None,
168 }
169 }
170}
171
172fn redact_args_for_audit(tool: &str, args: &Value) -> Value {
181 if tool != "run_command" {
182 return args.clone();
183 }
184 let mut redacted = args.clone();
185 match redacted.as_object_mut() {
186 Some(obj) => {
187 if obj.contains_key("command") {
188 obj.insert(
189 "command".to_string(),
190 Value::String("<redacted>".to_string()),
191 );
192 }
193 }
194 None => {
195 redacted = Value::String("<redacted: non-object args>".to_string());
198 }
199 }
200 redacted
201}
202
203fn iso8601_now() -> String {
206 let secs = SystemTime::now()
207 .duration_since(UNIX_EPOCH)
208 .map(|d| d.as_secs())
209 .unwrap_or(0);
210 format_iso8601_utc(secs)
211}
212
213fn format_iso8601_utc(secs: u64) -> String {
214 let days_since_epoch = secs / 86_400;
215 let day_secs = secs % 86_400;
216 let hour = day_secs / 3600;
217 let minute = (day_secs % 3600) / 60;
218 let second = day_secs % 60;
219 let (year, month, day) = civil_from_days(days_since_epoch as i64);
220 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
221}
222
223fn civil_from_days(z: i64) -> (i64, u32, u32) {
226 let z = z + 719_468;
227 let era = z.div_euclid(146_097);
228 let doe = (z - era * 146_097) as u64;
229 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
230 let y = yoe as i64 + era * 400;
231 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
232 let mp = (5 * doy + 2) / 153;
233 let d = doy - (153 * mp + 2) / 5 + 1;
234 let m = if mp < 10 { mp + 3 } else { mp - 9 };
235 let y = if m <= 2 { y + 1 } else { y };
236 (y, m as u32, d as u32)
237}
238
239pub fn default_audit_log_path() -> Option<PathBuf> {
241 audit_log_path_from_home(dirs::home_dir())
242}
243
244fn audit_log_path_from_home(home: Option<PathBuf>) -> Option<PathBuf> {
247 match home {
248 Some(h) => Some(h.join(".purple").join("mcp-audit.log")),
249 None => {
250 warn!("[purple] {}", messages::MCP_AUDIT_HOME_DIR_UNAVAILABLE);
251 None
252 }
253 }
254}
255
256#[derive(Debug, Deserialize)]
258pub struct JsonRpcRequest {
259 #[allow(dead_code)]
260 pub jsonrpc: String,
261 #[serde(default)]
262 pub id: Option<Value>,
263 pub method: String,
264 #[serde(default)]
265 pub params: Option<Value>,
266}
267
268#[derive(Debug, Serialize)]
270pub struct JsonRpcResponse {
271 pub jsonrpc: String,
272 #[serde(skip_serializing_if = "Option::is_none")]
273 pub id: Option<Value>,
274 #[serde(skip_serializing_if = "Option::is_none")]
275 pub result: Option<Value>,
276 #[serde(skip_serializing_if = "Option::is_none")]
277 pub error: Option<JsonRpcError>,
278}
279
280#[derive(Debug, Serialize)]
282pub struct JsonRpcError {
283 pub code: i64,
284 pub message: String,
285}
286
287impl JsonRpcResponse {
288 fn success(id: Option<Value>, result: Value) -> Self {
289 Self {
290 jsonrpc: "2.0".to_string(),
291 id,
292 result: Some(result),
293 error: None,
294 }
295 }
296
297 fn error(id: Option<Value>, code: i64, message: String) -> Self {
298 Self {
299 jsonrpc: "2.0".to_string(),
300 id,
301 result: None,
302 error: Some(JsonRpcError { code, message }),
303 }
304 }
305}
306
307fn mcp_tool_result(text: &str) -> Value {
309 serde_json::json!({
310 "content": [{"type": "text", "text": text}]
311 })
312}
313
314fn mcp_tool_error(text: &str) -> Value {
316 serde_json::json!({
317 "content": [{"type": "text", "text": text}],
318 "isError": true
319 })
320}
321
322fn require_config_exists(config_path: &Path) -> Result<(), Value> {
326 if !config_path.exists() {
327 return Err(mcp_tool_error(&messages::mcp_config_file_not_found(
328 &config_path.display(),
329 )));
330 }
331 Ok(())
332}
333
334fn verify_alias_exists(alias: &str, config_path: &Path) -> Result<(), Value> {
336 require_config_exists(config_path)?;
337 let config = match SshConfigFile::parse(config_path) {
338 Ok(c) => c,
339 Err(e) => return Err(mcp_tool_error(&format!("Failed to parse SSH config: {e}"))),
340 };
341 let exists = config.host_entries().iter().any(|h| h.alias == alias);
342 if !exists {
343 return Err(mcp_tool_error(&format!("Host not found: {alias}")));
344 }
345 Ok(())
346}
347
348fn ssh_exec(
350 alias: &str,
351 config_path: &Path,
352 command: &str,
353 timeout_secs: u64,
354) -> Result<(i32, String, String), Value> {
355 let config_str = config_path.to_string_lossy();
356 let child = match std::process::Command::new("ssh")
357 .args([
358 "-F",
359 &config_str,
360 "-o",
361 "ConnectTimeout=10",
362 "-o",
363 "BatchMode=yes",
364 "--",
365 alias,
366 command,
367 ])
368 .stdin(std::process::Stdio::null())
369 .stdout(std::process::Stdio::piped())
370 .stderr(std::process::Stdio::piped())
371 .spawn()
372 {
373 Ok(c) => c,
374 Err(e) => return Err(mcp_tool_error(&format!("Failed to spawn ssh: {e}"))),
375 };
376
377 let pid = child.id();
384 let (tx, rx) = std::sync::mpsc::channel();
385 std::thread::spawn(move || {
386 let _ = tx.send(child.wait_with_output());
387 });
388
389 match rx.recv_timeout(std::time::Duration::from_secs(timeout_secs)) {
390 Ok(Ok(out)) => {
391 let exit = out.status.code().unwrap_or(-1);
392 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
393 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
394 Ok((exit, stdout, stderr))
395 }
396 Ok(Err(e)) => Err(mcp_tool_error(&format!("Failed to wait for ssh: {e}"))),
397 Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
398 #[cfg(unix)]
399 {
400 let _ = std::process::Command::new("kill")
401 .arg("-TERM")
402 .arg(pid.to_string())
403 .status();
404 }
405 warn!("[external] MCP SSH command timed out after {timeout_secs}s (pid {pid})");
406 Err(mcp_tool_error(&format!(
407 "SSH command timed out after {timeout_secs} seconds"
408 )))
409 }
410 Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
411 Err(mcp_tool_error("ssh waiter thread disconnected"))
412 }
413 }
414}
415
416pub(crate) fn dispatch(method: &str, params: Option<Value>, ctx: &McpContext) -> JsonRpcResponse {
418 match method {
419 "initialize" => handle_initialize(),
420 "tools/list" => handle_tools_list(ctx),
421 "tools/call" => handle_tools_call(params, ctx),
422 _ => JsonRpcResponse::error(None, -32601, format!("Method not found: {method}")),
423 }
424}
425
426fn handle_initialize() -> JsonRpcResponse {
427 JsonRpcResponse::success(
428 None,
429 serde_json::json!({
430 "protocolVersion": "2024-11-05",
431 "capabilities": {
432 "tools": {}
433 },
434 "serverInfo": {
435 "name": "purple",
436 "version": env!("CARGO_PKG_VERSION")
437 }
438 }),
439 )
440}
441
442fn handle_tools_list(ctx: &McpContext) -> JsonRpcResponse {
443 let all_tools = all_tools_descriptor();
444 let tools = if ctx.options.read_only {
445 let filtered: Vec<Value> = all_tools
446 .as_array()
447 .map(|arr| {
448 arr.iter()
449 .filter(|t| {
450 t.get("name")
451 .and_then(|n| n.as_str())
452 .map(|n| READ_ONLY_TOOLS.contains(&n))
453 .unwrap_or(false)
454 })
455 .cloned()
456 .collect()
457 })
458 .unwrap_or_default();
459 serde_json::json!({ "tools": filtered })
460 } else {
461 serde_json::json!({ "tools": all_tools })
462 };
463 JsonRpcResponse::success(None, tools)
464}
465
466fn all_tools_descriptor() -> &'static Value {
470 static DESCRIPTOR: OnceLock<Value> = OnceLock::new();
471 DESCRIPTOR.get_or_init(build_all_tools_descriptor)
472}
473
474fn build_all_tools_descriptor() -> Value {
475 serde_json::json!([
476 {
477 "name": "list_hosts",
478 "description": "List all SSH hosts available to connect to. Returns alias, hostname, user, port, tags and provider for each host. Use the tag parameter to filter by tag, provider tag or provider name (fuzzy match). Call this first to discover available hosts.",
479 "annotations": {
480 "title": "List SSH hosts",
481 "readOnlyHint": true,
482 "destructiveHint": false,
483 "idempotentHint": true,
484 "openWorldHint": false
485 },
486 "inputSchema": {
487 "type": "object",
488 "properties": {
489 "tag": {
490 "type": "string",
491 "description": "Filter hosts by tag (fuzzy match against tags, provider_tags and provider name)"
492 }
493 }
494 }
495 },
496 {
497 "name": "get_host",
498 "description": "Get detailed information for a single SSH host including identity file, proxy jump, provider metadata, password source and tunnel count.",
499 "annotations": {
500 "title": "Get SSH host details",
501 "readOnlyHint": true,
502 "destructiveHint": false,
503 "idempotentHint": true,
504 "openWorldHint": false
505 },
506 "inputSchema": {
507 "type": "object",
508 "properties": {
509 "alias": {
510 "type": "string",
511 "description": "The host alias to look up"
512 }
513 },
514 "required": ["alias"]
515 }
516 },
517 {
518 "name": "run_command",
519 "description": "Run a shell command on a remote host via SSH. Non-interactive (BatchMode). Returns exit code, stdout and stderr. Suitable for diagnostic commands, not interactive programs.",
520 "annotations": {
521 "title": "Run shell command on SSH host",
522 "readOnlyHint": false,
523 "destructiveHint": true,
524 "idempotentHint": false,
525 "openWorldHint": true
526 },
527 "inputSchema": {
528 "type": "object",
529 "properties": {
530 "alias": {
531 "type": "string",
532 "description": "The host alias to connect to"
533 },
534 "command": {
535 "type": "string",
536 "description": "The command to execute"
537 },
538 "timeout": {
539 "type": "integer",
540 "description": "Timeout in seconds (default 30)",
541 "default": 30,
542 "minimum": 1,
543 "maximum": 300
544 }
545 },
546 "required": ["alias", "command"]
547 }
548 },
549 {
550 "name": "list_containers",
551 "description": "List all Docker or Podman containers on a remote host via SSH. Auto-detects the container runtime. Returns container ID, name, image, state, status and ports.",
552 "annotations": {
553 "title": "List containers on SSH host",
554 "readOnlyHint": true,
555 "destructiveHint": false,
556 "idempotentHint": true,
557 "openWorldHint": false
558 },
559 "inputSchema": {
560 "type": "object",
561 "properties": {
562 "alias": {
563 "type": "string",
564 "description": "The host alias to list containers for"
565 }
566 },
567 "required": ["alias"]
568 }
569 },
570 {
571 "name": "container_action",
572 "description": "Start, stop or restart a Docker or Podman container on a remote host via SSH. Auto-detects the container runtime.",
573 "annotations": {
574 "title": "Start, stop or restart container",
575 "readOnlyHint": false,
576 "destructiveHint": true,
577 "idempotentHint": false,
578 "openWorldHint": false
579 },
580 "inputSchema": {
581 "type": "object",
582 "properties": {
583 "alias": {
584 "type": "string",
585 "description": "The host alias"
586 },
587 "container_id": {
588 "type": "string",
589 "description": "The container ID or name"
590 },
591 "action": {
592 "type": "string",
593 "description": "The action to perform",
594 "enum": ["start", "stop", "restart"]
595 }
596 },
597 "required": ["alias", "container_id", "action"]
598 }
599 }
600 ])
601}
602
603fn handle_tools_call(params: Option<Value>, ctx: &McpContext) -> JsonRpcResponse {
604 let params = match params {
605 Some(p) => p,
606 None => {
607 return JsonRpcResponse::error(
608 None,
609 -32602,
610 "Invalid params: missing params object".to_string(),
611 );
612 }
613 };
614
615 let tool_name = match params.get("name").and_then(|n| n.as_str()) {
616 Some(n) => n,
617 None => {
618 return JsonRpcResponse::error(
619 None,
620 -32602,
621 "Invalid params: missing tool name".to_string(),
622 );
623 }
624 };
625
626 let args = params
627 .get("arguments")
628 .cloned()
629 .unwrap_or(serde_json::json!({}));
630
631 if !ctx.is_tool_allowed(tool_name) {
632 debug!("MCP tool denied (read-only mode): tool={tool_name}");
633 let result = mcp_tool_error(messages::MCP_TOOL_DENIED_READ_ONLY);
634 if let Some(audit) = ctx.audit.as_ref() {
635 audit.record(tool_name, &args, AuditOutcome::Denied);
636 }
637 return JsonRpcResponse::success(None, result);
638 }
639
640 let result = match tool_name {
641 "list_hosts" => tool_list_hosts(&args, &ctx.config_path),
642 "get_host" => tool_get_host(&args, &ctx.config_path),
643 "run_command" => tool_run_command(&args, &ctx.config_path),
644 "list_containers" => tool_list_containers(&args, &ctx.config_path),
645 "container_action" => tool_container_action(&args, &ctx.config_path),
646 _ => mcp_tool_error(&format!("Unknown tool: {tool_name}")),
647 };
648
649 if let Some(audit) = ctx.audit.as_ref() {
650 let outcome = if result.get("isError").and_then(|v| v.as_bool()) == Some(true) {
651 AuditOutcome::Error
652 } else {
653 AuditOutcome::Allowed
654 };
655 audit.record(tool_name, &args, outcome);
656 }
657
658 JsonRpcResponse::success(None, result)
659}
660
661fn tool_list_hosts(args: &Value, config_path: &Path) -> Value {
662 if let Err(e) = require_config_exists(config_path) {
663 return e;
664 }
665 let config = match SshConfigFile::parse(config_path) {
666 Ok(c) => c,
667 Err(e) => return mcp_tool_error(&format!("Failed to parse SSH config: {e}")),
668 };
669
670 let entries = config.host_entries();
671 let tag_filter = args.get("tag").and_then(|t| t.as_str());
672
673 let hosts: Vec<Value> = entries
674 .iter()
675 .filter(|entry| {
676 if is_host_pattern(&entry.alias) {
678 return false;
679 }
680
681 if let Some(tag) = tag_filter {
683 let tag_lower = tag.to_lowercase();
684 let matches_tags = entry
685 .tags
686 .iter()
687 .any(|t| t.to_lowercase().contains(&tag_lower));
688 let matches_provider_tags = entry
689 .provider_tags
690 .iter()
691 .any(|t| t.to_lowercase().contains(&tag_lower));
692 let matches_provider = entry
693 .provider
694 .as_ref()
695 .is_some_and(|p| p.to_lowercase().contains(&tag_lower));
696 if !matches_tags && !matches_provider_tags && !matches_provider {
697 return false;
698 }
699 }
700
701 true
702 })
703 .map(|entry| {
704 serde_json::json!({
705 "alias": entry.alias,
706 "hostname": entry.hostname,
707 "user": entry.user,
708 "port": entry.port,
709 "tags": entry.tags,
710 "provider": entry.provider,
711 "stale": entry.stale.is_some(),
712 })
713 })
714 .collect();
715
716 let json_str = serde_json::to_string_pretty(&hosts)
717 .expect("serde_json::json! values are always serialisable");
718 mcp_tool_result(&json_str)
719}
720
721fn tool_get_host(args: &Value, config_path: &Path) -> Value {
722 let alias = match args.get("alias").and_then(|a| a.as_str()) {
723 Some(a) if !a.is_empty() => a,
724 _ => return mcp_tool_error("Missing required parameter: alias"),
725 };
726
727 if let Err(e) = require_config_exists(config_path) {
728 return e;
729 }
730 let config = match SshConfigFile::parse(config_path) {
731 Ok(c) => c,
732 Err(e) => return mcp_tool_error(&format!("Failed to parse SSH config: {e}")),
733 };
734
735 let entries = config.host_entries();
736 let entry = entries.iter().find(|e| e.alias == alias);
737
738 match entry {
739 Some(entry) => {
740 let meta: serde_json::Map<String, Value> = entry
741 .provider_meta
742 .iter()
743 .map(|(k, v)| (k.clone(), Value::String(v.clone())))
744 .collect();
745
746 let host = serde_json::json!({
747 "alias": entry.alias,
748 "hostname": entry.hostname,
749 "user": entry.user,
750 "port": entry.port,
751 "identity_file": entry.identity_file,
752 "proxy_jump": entry.proxy_jump,
753 "tags": entry.tags,
754 "provider_tags": entry.provider_tags,
755 "provider": entry.provider,
756 "provider_meta": meta,
757 "askpass": entry.askpass,
758 "tunnel_count": entry.tunnel_count,
759 "stale": entry.stale.is_some(),
760 });
761
762 let json_str = serde_json::to_string_pretty(&host)
763 .expect("serde_json::json! values are always serialisable");
764 mcp_tool_result(&json_str)
765 }
766 None => mcp_tool_error(&format!("Host not found: {alias}")),
767 }
768}
769
770fn tool_run_command(args: &Value, config_path: &Path) -> Value {
771 let alias = match args.get("alias").and_then(|a| a.as_str()) {
772 Some(a) if !a.is_empty() => a,
773 _ => return mcp_tool_error("Missing required parameter: alias"),
774 };
775 let command = match args.get("command").and_then(|c| c.as_str()) {
776 Some(c) if !c.is_empty() => c,
777 _ => return mcp_tool_error("Missing required parameter: command"),
778 };
779 let timeout_secs = args
783 .get("timeout")
784 .and_then(|t| t.as_u64())
785 .unwrap_or(30)
786 .clamp(1, 300);
787
788 if let Err(e) = verify_alias_exists(alias, config_path) {
789 return e;
790 }
791
792 debug!("MCP tool: run_command alias={alias}");
796 match ssh_exec(alias, config_path, command, timeout_secs) {
797 Ok((exit_code, stdout, stderr)) => {
798 if exit_code != 0 {
799 error!("[external] MCP ssh_exec failed: alias={alias} exit={exit_code}");
800 }
801 let result = serde_json::json!({
802 "exit_code": exit_code,
803 "stdout": stdout,
804 "stderr": stderr
805 });
806 let json_str = serde_json::to_string_pretty(&result)
807 .expect("serde_json::json! values are always serialisable");
808 mcp_tool_result(&json_str)
809 }
810 Err(e) => e,
811 }
812}
813
814fn tool_list_containers(args: &Value, config_path: &Path) -> Value {
815 let alias = match args.get("alias").and_then(|a| a.as_str()) {
816 Some(a) if !a.is_empty() => a,
817 _ => return mcp_tool_error("Missing required parameter: alias"),
818 };
819
820 if let Err(e) = verify_alias_exists(alias, config_path) {
821 return e;
822 }
823
824 let command = crate::containers::container_list_command(None);
826
827 let (exit_code, stdout, stderr) = match ssh_exec(alias, config_path, &command, 30) {
828 Ok(r) => r,
829 Err(e) => return e,
830 };
831
832 if exit_code != 0 {
833 return mcp_tool_error(&format!("SSH command failed: {}", stderr.trim()));
834 }
835
836 match crate::containers::parse_container_output(&stdout, None) {
837 Ok((runtime, containers)) => {
838 let containers_json: Vec<Value> = containers
839 .iter()
840 .map(|c| {
841 serde_json::json!({
842 "id": c.id,
843 "name": c.names,
844 "image": c.image,
845 "state": c.state,
846 "status": c.status,
847 "ports": c.ports,
848 })
849 })
850 .collect();
851 let result = serde_json::json!({
852 "runtime": runtime.as_str(),
853 "containers": containers_json,
854 });
855 let json_str = serde_json::to_string_pretty(&result)
856 .expect("serde_json::json! values are always serialisable");
857 mcp_tool_result(&json_str)
858 }
859 Err(e) => mcp_tool_error(&e),
860 }
861}
862
863fn tool_container_action(args: &Value, config_path: &Path) -> Value {
864 let alias = match args.get("alias").and_then(|a| a.as_str()) {
865 Some(a) if !a.is_empty() => a,
866 _ => return mcp_tool_error("Missing required parameter: alias"),
867 };
868 let container_id = match args.get("container_id").and_then(|c| c.as_str()) {
869 Some(c) if !c.is_empty() => c,
870 _ => return mcp_tool_error("Missing required parameter: container_id"),
871 };
872 let action_str = match args.get("action").and_then(|a| a.as_str()) {
873 Some(a) => a,
874 None => return mcp_tool_error("Missing required parameter: action"),
875 };
876
877 if let Err(e) = crate::containers::validate_container_id(container_id) {
879 return mcp_tool_error(&e);
880 }
881
882 let action = match action_str {
883 "start" => crate::containers::ContainerAction::Start,
884 "stop" => crate::containers::ContainerAction::Stop,
885 "restart" => crate::containers::ContainerAction::Restart,
886 _ => {
887 return mcp_tool_error(&format!(
888 "Invalid action: {action_str}. Must be start, stop or restart"
889 ));
890 }
891 };
892
893 if let Err(e) = verify_alias_exists(alias, config_path) {
894 return e;
895 }
896
897 let detect_cmd = crate::containers::container_list_command(None);
899
900 let (detect_exit, detect_stdout, detect_stderr) =
901 match ssh_exec(alias, config_path, &detect_cmd, 30) {
902 Ok(r) => r,
903 Err(e) => return e,
904 };
905
906 if detect_exit != 0 {
907 return mcp_tool_error(&format!(
908 "Failed to detect container runtime: {}",
909 detect_stderr.trim()
910 ));
911 }
912
913 let runtime = match crate::containers::parse_container_output(&detect_stdout, None) {
914 Ok((rt, _)) => rt,
915 Err(e) => return mcp_tool_error(&format!("Failed to detect container runtime: {e}")),
916 };
917
918 let action_command = crate::containers::container_action_command(runtime, action, container_id);
919
920 let (action_exit, _action_stdout, action_stderr) =
921 match ssh_exec(alias, config_path, &action_command, 30) {
922 Ok(r) => r,
923 Err(e) => return e,
924 };
925
926 if action_exit == 0 {
927 let past = match action_str {
928 "start" => "started",
929 "stop" => "stopped",
930 "restart" => "restarted",
931 other => other,
932 };
933 let result = serde_json::json!({
934 "success": true,
935 "message": format!("Container {container_id} {past}"),
936 });
937 let json_str = serde_json::to_string_pretty(&result)
938 .expect("serde_json::json! values are always serialisable");
939 mcp_tool_result(&json_str)
940 } else {
941 mcp_tool_error(&format!(
942 "Container action failed: {}",
943 action_stderr.trim()
944 ))
945 }
946}
947
948pub fn run(config_path: &Path, options: McpOptions) -> anyhow::Result<()> {
951 info!(
952 "MCP server starting (read_only={}, audit_log={})",
953 options.read_only,
954 options
955 .audit_log_path
956 .as_ref()
957 .map(|p| p.display().to_string())
958 .unwrap_or_else(|| "disabled".to_string())
959 );
960 let ctx = McpContext::new(config_path.to_path_buf(), options);
961
962 let stdin = std::io::stdin();
963 let stdout = std::io::stdout();
964 let reader = stdin.lock();
965 let mut writer = stdout.lock();
966
967 for line in reader.lines() {
968 let line = match line {
969 Ok(l) => l,
970 Err(_) => break,
971 };
972 let trimmed = line.trim();
973 if trimmed.is_empty() {
974 continue;
975 }
976
977 let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
978 Ok(r) => r,
979 Err(_) => {
980 let resp = JsonRpcResponse::error(None, -32700, "Parse error".to_string());
981 let json = serde_json::to_string(&resp)?;
982 writeln!(writer, "{json}")?;
983 writer.flush()?;
984 continue;
985 }
986 };
987
988 if request.id.is_none() {
990 debug!("MCP notification: {}", request.method);
991 continue;
992 }
993
994 debug!("MCP request: method={}", request.method);
995 let mut response = dispatch(&request.method, request.params, &ctx);
996 debug!(
997 "MCP response: method={} success={}",
998 request.method,
999 response.error.is_none()
1000 );
1001 response.id = request.id;
1002
1003 let json = serde_json::to_string(&response)?;
1004 writeln!(writer, "{json}")?;
1005 writer.flush()?;
1006 }
1007
1008 Ok(())
1009}
1010
1011#[cfg(test)]
1012#[path = "mcp_tests.rs"]
1013mod tests;