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 && !parent.as_os_str().is_empty()
85 {
86 std::fs::create_dir_all(parent)?;
87 }
91 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 #[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> {
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#[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#[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#[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
309fn mcp_tool_result(text: &str) -> Value {
311 serde_json::json!({
312 "content": [{"type": "text", "text": text}]
313 })
314}
315
316fn mcp_tool_error(text: &str) -> Value {
318 serde_json::json!({
319 "content": [{"type": "text", "text": text}],
320 "isError": true
321 })
322}
323
324fn 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
336fn 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
354fn 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 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
422pub(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
472fn 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 if is_host_pattern(&entry.alias) {
684 return false;
685 }
686
687 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 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 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 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 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 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 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
971pub 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 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;