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!("[purple] 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!("[purple] MCP tool: run_command alias={alias}");
806 match ssh_exec(alias, config_path, command, timeout_secs) {
807 Ok((exit_code, stdout, stderr)) => {
808 match crate::connection::classify_ssh_exit(exit_code) {
812 crate::connection::SshExitClass::TransportFailure => {
813 error!("[external] MCP ssh_exec failed: alias={alias} exit={exit_code}");
814 }
815 crate::connection::SshExitClass::RemoteStatus => {
816 debug!("[external] MCP ssh_exec exit: alias={alias} exit={exit_code}");
817 }
818 crate::connection::SshExitClass::Success => {}
819 }
820 let result = serde_json::json!({
821 "exit_code": exit_code,
822 "stdout": stdout,
823 "stderr": stderr
824 });
825 let json_str = serde_json::to_string_pretty(&result)
826 .expect("serde_json::json! values are always serialisable");
827 mcp_tool_result(&json_str)
828 }
829 Err(e) => e,
830 }
831}
832
833fn tool_list_containers(args: &Value, config_path: &Path, env: &crate::runtime::env::Env) -> Value {
834 let alias = match args.get("alias").and_then(|a| a.as_str()) {
835 Some(a) if !a.is_empty() => a,
836 _ => return mcp_tool_error("Missing required parameter: alias"),
837 };
838
839 if let Err(e) = verify_alias_exists(alias, config_path, env) {
840 return e;
841 }
842
843 let command = crate::containers::container_list_command(None);
845
846 let (exit_code, stdout, stderr) = match ssh_exec(alias, config_path, &command, 30) {
847 Ok(r) => r,
848 Err(e) => return e,
849 };
850
851 if exit_code != 0 {
852 return mcp_tool_error(&format!("SSH command failed: {}", stderr.trim()));
853 }
854
855 match crate::containers::parse_container_output(&stdout, None) {
856 Ok(listing) => {
857 let containers_json: Vec<Value> = listing
858 .containers
859 .iter()
860 .map(|c| {
861 serde_json::json!({
862 "id": c.id,
863 "name": c.names,
864 "image": c.image,
865 "state": c.state,
866 "status": c.status,
867 "ports": c.ports,
868 })
869 })
870 .collect();
871 let mut result = serde_json::json!({
872 "runtime": listing.runtime.as_str(),
873 "containers": containers_json,
874 });
875 if let Some(v) = listing.engine_version {
876 result["engine_version"] = serde_json::Value::String(v);
877 }
878 let json_str = serde_json::to_string_pretty(&result)
879 .expect("serde_json::json! values are always serialisable");
880 mcp_tool_result(&json_str)
881 }
882 Err(e) => mcp_tool_error(&e),
883 }
884}
885
886fn tool_container_action(
887 args: &Value,
888 config_path: &Path,
889 env: &crate::runtime::env::Env,
890) -> Value {
891 let alias = match args.get("alias").and_then(|a| a.as_str()) {
892 Some(a) if !a.is_empty() => a,
893 _ => return mcp_tool_error("Missing required parameter: alias"),
894 };
895 let container_id = match args.get("container_id").and_then(|c| c.as_str()) {
896 Some(c) if !c.is_empty() => c,
897 _ => return mcp_tool_error("Missing required parameter: container_id"),
898 };
899 let action_str = match args.get("action").and_then(|a| a.as_str()) {
900 Some(a) => a,
901 None => return mcp_tool_error("Missing required parameter: action"),
902 };
903
904 if let Err(e) = crate::containers::validate_container_id(container_id) {
906 return mcp_tool_error(&e);
907 }
908
909 let action = match action_str {
910 "start" => crate::containers::ContainerAction::Start,
911 "stop" => crate::containers::ContainerAction::Stop,
912 "restart" => crate::containers::ContainerAction::Restart,
913 _ => {
914 return mcp_tool_error(&format!(
915 "Invalid action: {action_str}. Must be start, stop or restart"
916 ));
917 }
918 };
919
920 if let Err(e) = verify_alias_exists(alias, config_path, env) {
921 return e;
922 }
923
924 let detect_cmd = crate::containers::container_list_command(None);
926
927 let (detect_exit, detect_stdout, detect_stderr) =
928 match ssh_exec(alias, config_path, &detect_cmd, 30) {
929 Ok(r) => r,
930 Err(e) => return e,
931 };
932
933 if detect_exit != 0 {
934 return mcp_tool_error(&format!(
935 "Failed to detect container runtime: {}",
936 detect_stderr.trim()
937 ));
938 }
939
940 let runtime = match crate::containers::parse_container_output(&detect_stdout, None) {
941 Ok(listing) => listing.runtime,
942 Err(e) => return mcp_tool_error(&format!("Failed to detect container runtime: {e}")),
943 };
944
945 let action_command = crate::containers::container_action_command(runtime, action, container_id);
946
947 let (action_exit, _action_stdout, action_stderr) =
948 match ssh_exec(alias, config_path, &action_command, 30) {
949 Ok(r) => r,
950 Err(e) => return e,
951 };
952
953 if action_exit == 0 {
954 let past = match action_str {
955 "start" => "started",
956 "stop" => "stopped",
957 "restart" => "restarted",
958 other => other,
959 };
960 let result = serde_json::json!({
961 "success": true,
962 "message": format!("Container {container_id} {past}"),
963 });
964 let json_str = serde_json::to_string_pretty(&result)
965 .expect("serde_json::json! values are always serialisable");
966 mcp_tool_result(&json_str)
967 } else {
968 mcp_tool_error(&format!(
969 "Container action failed: {}",
970 action_stderr.trim()
971 ))
972 }
973}
974
975pub fn run(
978 config_path: &Path,
979 options: McpOptions,
980 env: std::sync::Arc<crate::runtime::env::Env>,
981) -> anyhow::Result<()> {
982 info!(
983 "[purple] MCP server starting (read_only={}, audit_log={})",
984 options.read_only,
985 options
986 .audit_log_path
987 .as_ref()
988 .map(|p| p.display().to_string())
989 .unwrap_or_else(|| "disabled".to_string())
990 );
991 let ctx = McpContext::new(config_path.to_path_buf(), options, env);
992
993 let stdin = std::io::stdin();
994 let stdout = std::io::stdout();
995 let reader = stdin.lock();
996 let mut writer = stdout.lock();
997
998 for line in reader.lines() {
999 let line = match line {
1000 Ok(l) => l,
1001 Err(_) => break,
1002 };
1003 let trimmed = line.trim();
1004 if trimmed.is_empty() {
1005 continue;
1006 }
1007
1008 let request: JsonRpcRequest = match serde_json::from_str(trimmed) {
1009 Ok(r) => r,
1010 Err(_) => {
1011 let resp = JsonRpcResponse::error(None, -32700, "Parse error".to_string());
1012 let json = serde_json::to_string(&resp)?;
1013 writeln!(writer, "{json}")?;
1014 writer.flush()?;
1015 continue;
1016 }
1017 };
1018
1019 if request.id.is_none() {
1021 debug!("[purple] MCP notification: {}", request.method);
1022 continue;
1023 }
1024
1025 debug!("[purple] MCP request: method={}", request.method);
1026 let mut response = dispatch(&request.method, request.params, &ctx);
1027 debug!(
1028 "[purple] MCP response: method={} success={}",
1029 request.method,
1030 response.error.is_none()
1031 );
1032 response.id = request.id;
1033
1034 let json = serde_json::to_string(&response)?;
1035 writeln!(writer, "{json}")?;
1036 writer.flush()?;
1037 }
1038
1039 Ok(())
1040}
1041
1042#[cfg(test)]
1043#[path = "mcp_tests.rs"]
1044mod tests;