Skip to main content

ssh_mcp/
server.rs

1//! MCP Server implementation
2//!
3//! This module provides the main MCP server that integrates SSH connection
4//! management with the `shell` and `sudo_shell` tools.
5
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
9use std::time::Duration;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use rmcp::{
13    ErrorData as McpError,
14    handler::server::ServerHandler,
15    model::*,
16    service::{RequestContext, RoleServer},
17};
18use tokio::sync::Mutex;
19use tracing::{debug, error, info, warn};
20
21use crate::background::detach::{DetachMode, DetachProbeOutput, DetachProbeRequest};
22use crate::background::job::NewRunningJob;
23use crate::background::wrapper::{
24    build_background_wrapper_script_full, build_background_wrapper_script_portable,
25};
26use crate::background::{JobRegistry, JobState, LocalLogSpooler, SharedJobState};
27use crate::config::Config;
28use crate::error::{Result, SshMcpError};
29#[cfg(unix)]
30use crate::platform::O_NOFOLLOW_FLAG;
31#[cfg(test)]
32use crate::server::validation::read_file::{
33    READ_FILE_BYTES_PER_TOKEN, READ_FILE_DEFAULT_PREVIEW_LINES, READ_FILE_HARD_MAX_BYTES,
34    READ_FILE_MAX_LINE_WINDOW,
35};
36#[cfg(test)]
37use crate::server::validation::read_file::{
38    estimate_tokens_from_bytes, resolve_read_file_line_limit, resolve_read_file_max_bytes,
39};
40#[cfg(test)]
41use crate::server::validation::validate_background_log_path;
42use crate::ssh::{
43    CommandOutput, SshConfig, SshConnectionManager, sanitize_command, wrap_sudo_command,
44};
45use crate::tools::{ApplyPatchParams, CheckProcessParams, ReadFileMode, ReadFileParams};
46use crate::transfer::{TransferEngine, TransferParams, TransferRunContext, TransferSshOptions};
47
48mod args;
49mod exec;
50mod handlers;
51mod testing;
52mod tools;
53mod validation;
54
55const BACKGROUND_START_TIMEOUT: Duration = Duration::from_secs(20);
56const READ_FILE_ERROR_MARKER: &str = "__SSH_MCP_READ_FILE_ERR__";
57
58const JOB_COMPLETED_RETENTION: Duration = Duration::from_secs(60 * 60);
59
60static JOB_COUNTER: AtomicU64 = AtomicU64::new(0);
61
62fn make_job_id() -> String {
63    let counter = JOB_COUNTER.fetch_add(1, Ordering::Relaxed);
64    let epoch_ms = SystemTime::now()
65        .duration_since(UNIX_EPOCH)
66        .map(|d| d.as_millis())
67        .unwrap_or(0);
68    format!("{}-{}", epoch_ms, counter)
69}
70
71fn build_background_wrapper_script(
72    mode: DetachMode,
73    job_id: &str,
74    user_command: &str,
75    log_path: &str,
76) -> String {
77    match mode {
78        DetachMode::Full | DetachMode::Unknown => {
79            build_background_wrapper_script_full(job_id, user_command, log_path)
80        }
81        DetachMode::Portable => {
82            build_background_wrapper_script_portable(job_id, user_command, log_path)
83        }
84        DetachMode::DirectOnly => {
85            build_background_wrapper_script_portable(job_id, user_command, log_path)
86        }
87    }
88}
89
90/// SSH MCP Server
91///
92/// The main server implementation that provides MCP tools for remote SSH
93/// command execution.
94#[derive(Clone)]
95pub struct SshMcpServer {
96    /// Server configuration
97    config: Config,
98
99    /// SSH connection manager
100    connection: Arc<SshConnectionManager>,
101
102    /// Command execution timeout
103    timeout: Duration,
104
105    /// Maximum command length
106    max_chars: Option<usize>,
107
108    detach_mode: Arc<AtomicU8>,
109    detach_mode_lock: Arc<Mutex<()>>,
110
111    spooler: Arc<LocalLogSpooler>,
112    job_registry: Arc<JobRegistry>,
113
114    transfer: TransferEngine,
115}
116
117impl SshMcpServer {
118    /// Create a new SSH MCP Server
119    ///
120    /// This sets up the SSH connection manager based on the provided configuration.
121    /// Connection is not established until a tool is actually used.
122    pub async fn new(config: Config) -> Result<Self> {
123        let local_root = std::env::current_dir()?;
124
125        let spooler = Arc::new(LocalLogSpooler::new_default());
126        spooler.ensure_dir().await.map_err(|e| {
127            SshMcpError::Config(format!(
128                "failed to initialize local log spool dir {}: {e}",
129                spooler.base_dir().display()
130            ))
131        })?;
132        let job_registry = Arc::new(JobRegistry::new(JOB_COMPLETED_RETENTION));
133
134        // Build SSH configuration
135        let mut ssh_config = SshConfig::new(&config.host, &config.user).with_port(config.port);
136
137        // Add authentication
138        if let Some(ref password) = config.password {
139            ssh_config = ssh_config.with_password(password);
140        }
141
142        if let Some(ref key_path) = config.key {
143            // Read the key file
144            let key_content = tokio::fs::read_to_string(key_path)
145                .await
146                .map_err(SshMcpError::Io)?;
147            ssh_config = ssh_config.with_private_key(&key_content);
148        }
149
150        // Add elevation passwords if provided
151        if let Some(ref su_password) = config.su_password {
152            ssh_config = ssh_config.with_su_password(su_password);
153        }
154
155        if let Some(ref sudo_password) = config.sudo_password {
156            ssh_config = ssh_config.with_sudo_password(sudo_password);
157        }
158
159        // Add keepalive settings for human-like connection persistence
160        ssh_config = ssh_config
161            .with_keepalive_interval(config.keepalive_interval)
162            .with_keepalive_max(config.keepalive_max);
163
164        // Add reconnect and health probe settings
165        ssh_config = ssh_config
166            .with_reconnect_retries(config.reconnect_retries)
167            .with_reconnect_backoff_ms(config.reconnect_backoff_ms)
168            .with_health_probe_timeout_ms(config.health_probe_timeout_ms);
169
170        // Add host key verification settings
171        ssh_config = ssh_config
172            .with_host_key_checking(config.strict_host_key_checking)
173            .with_known_hosts(config.known_hosts.clone());
174
175        // Add output token limit for OOM protection
176        ssh_config = ssh_config.with_max_output_tokens(config.max_output_tokens);
177
178        // Create connection manager
179        let connection = Arc::new(SshConnectionManager::new(ssh_config).await);
180
181        let timeout = Duration::from_millis(config.timeout_ms);
182        let max_chars = config.max_chars;
183
184        Ok(Self {
185            config,
186            connection,
187            timeout,
188            max_chars,
189            detach_mode: Arc::new(AtomicU8::new(DetachMode::Unknown.as_u8())),
190            detach_mode_lock: Arc::new(Mutex::new(())),
191            spooler,
192            job_registry,
193            transfer: TransferEngine::new(local_root),
194        })
195    }
196
197    fn connection_id(&self) -> String {
198        format!(
199            "{}@{}:{}",
200            self.config.user, self.config.host, self.config.port
201        )
202    }
203
204    fn default_local_log_path(
205        &self,
206        job_id: &str,
207    ) -> std::result::Result<(PathBuf, String), String> {
208        let path = self
209            .spooler
210            .log_path_for(job_id)
211            .map_err(|e| format!("failed to generate local log path for job_id='{job_id}': {e}"))?;
212        let path_str = path.to_string_lossy().to_string();
213        Ok((path, path_str))
214    }
215
216    async fn ensure_local_log_file(&self, log_path: &Path) -> std::result::Result<(), SshMcpError> {
217        self.spooler.ensure_dir().await.map_err(|e| {
218            SshMcpError::Config(format!(
219                "failed to ensure local log spool dir {}: {e}",
220                self.spooler.base_dir().display()
221            ))
222        })?;
223
224        if log_path.parent() != Some(self.spooler.base_dir()) {
225            return Err(SshMcpError::InvalidParams(format!(
226                "log_path must be directly under {}",
227                self.spooler.base_dir().display()
228            )));
229        }
230
231        match tokio::fs::symlink_metadata(log_path).await {
232            Ok(meta) => {
233                let ft = meta.file_type();
234                if ft.is_symlink() {
235                    return Err(SshMcpError::invalid_params(
236                        "log_path is a symlink (refusing to follow it)",
237                    ));
238                }
239                if !ft.is_file() {
240                    return Err(SshMcpError::invalid_params(
241                        "log_path exists but is not a regular file",
242                    ));
243                }
244            }
245            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
246            Err(e) => return Err(SshMcpError::Io(e)),
247        }
248
249        let mut opts = tokio::fs::OpenOptions::new();
250        opts.write(true).create(true).truncate(true);
251
252        #[cfg(unix)]
253        {
254            opts.custom_flags(O_NOFOLLOW_FLAG);
255        }
256
257        let file = match opts.open(log_path).await {
258            Ok(f) => f,
259            Err(e) => {
260                if let Ok(meta) = tokio::fs::symlink_metadata(log_path).await
261                    && meta.file_type().is_symlink()
262                {
263                    return Err(SshMcpError::invalid_params(
264                        "log_path is a symlink (refusing to follow it)",
265                    ));
266                }
267                return Err(SshMcpError::Io(e));
268            }
269        };
270
271        file.sync_all().await.map_err(SshMcpError::Io)
272    }
273
274    async fn register_running_job(
275        &self,
276        job_id: &str,
277        pid: u32,
278        log_path: PathBuf,
279        command: &str,
280    ) -> SharedJobState {
281        let job = Arc::new(Mutex::new(JobState::new_running(NewRunningJob {
282            job_id: job_id.to_string(),
283            pid,
284            log_path,
285            command: command.to_string(),
286            connection_id: self.connection_id(),
287        })));
288
289        self.job_registry
290            .insert(job_id.to_string(), Arc::clone(&job))
291            .await;
292
293        let persisted = {
294            let guard = job.lock().await;
295            guard.clone()
296        };
297        if let Err(e) = self.spooler.persist_job_state(&persisted).await {
298            warn!(job_id = ?job_id, error = ?e, "failed to persist running job state");
299        }
300
301        job
302    }
303
304    /// Get a reference to the SSH connection manager
305    pub fn connection(&self) -> &Arc<SshConnectionManager> {
306        &self.connection
307    }
308
309    /// Close the server and cleanup resources
310    pub async fn shutdown(&self) {
311        info!("Shutting down SSH MCP Server...");
312        self.connection.close().await;
313    }
314
315    async fn determine_detach_mode(&self) -> Result<DetachMode> {
316        let server = self.clone();
317        crate::background::detach::determine_detach_mode(
318            self.detach_mode.as_ref(),
319            self.detach_mode_lock.as_ref(),
320            make_job_id,
321            move |req, timeout| {
322                let server = server.clone();
323                async move { server.exec_detach_probe(req, timeout).await }
324            },
325        )
326        .await
327    }
328
329    async fn exec_detach_probe(
330        &self,
331        req: DetachProbeRequest,
332        timeout: Duration,
333    ) -> Result<DetachProbeOutput> {
334        let output = self.connection.exec_command(&req.wrapper, timeout).await?;
335        Ok(DetachProbeOutput {
336            stdout: output.stdout,
337            stderr: output.stderr,
338            exit_code: output.exit_code,
339        })
340    }
341
342    /// Execute a command (used by shell tool)
343    async fn execute_command_with_timeout(
344        &self,
345        command: &str,
346        timeout: Duration,
347    ) -> std::result::Result<CallToolResult, McpError> {
348        debug!(
349            "shell tool called: cmd_len={}, background=false, sudo=false, timeout_ms={}",
350            command.len(),
351            timeout.as_millis()
352        );
353
354        // Sanitize the command
355        let sanitized = match self.sanitize_or_tool_error(command) {
356            Ok(cmd) => cmd,
357            Err(result) => return Ok(result),
358        };
359
360        // Foreground execution is detachable-by-design:
361        // - Start the command on a dedicated SSH channel
362        // - Stream remote stdout/stderr into a local spool file
363        // - If timeout elapses, return JSON with job_id/pid/log_path while the stream continues
364
365        let requires_elevation = self.connection.get_su_password().is_some();
366        if requires_elevation {
367            if let Err(e) = self.connection.ensure_connected().await {
368                error!(error = ?e, "Failed to ensure SSH connection");
369                return Ok(CallToolResult::error(vec![Content::text(e.to_string())]));
370            }
371
372            if let Err(e) = self.connection.ensure_elevated().await {
373                debug!(error = ?e, "Elevation failed, will run as normal user");
374            }
375        }
376
377        let detach_mode = match self.determine_detach_mode().await {
378            Ok(mode) => mode,
379            Err(e) => {
380                debug!(error = ?e, "detach-mode probe failed; falling back to direct foreground exec");
381                DetachMode::DirectOnly
382            }
383        };
384        if detach_mode == DetachMode::DirectOnly {
385            match self.connection.exec_command(&sanitized, timeout).await {
386                Ok(output) => return Ok(Self::calltool_from_command_output(output)),
387                Err(e) => {
388                    error!(error = ?e, "Command execution failed");
389                    let mut msg = format!("Error: {}", e);
390                    if matches!(e, SshMcpError::Timeout(_)) {
391                        msg.push_str("\nHint: background detach is not supported on this target; rerun with background=true or a larger timeout_ms.");
392                    }
393                    return Ok(CallToolResult::error(vec![Content::text(msg)]));
394                }
395            }
396        }
397
398        // Ensure connection is established for detached foreground execution path.
399        if !requires_elevation && let Err(e) = self.connection.ensure_connected().await {
400            error!(error = ?e, "Failed to ensure SSH connection");
401            return Ok(CallToolResult::error(vec![Content::text(e.to_string())]));
402        }
403
404        self.execute_detachable_foreground_impl(detach_mode, &sanitized, &sanitized, timeout)
405            .await
406    }
407
408    async fn execute_command(
409        &self,
410        command: &str,
411    ) -> std::result::Result<CallToolResult, McpError> {
412        self.execute_command_with_timeout(command, self.timeout)
413            .await
414    }
415
416    async fn execute_background_command(
417        &self,
418        command: &str,
419        log_path: Option<&str>,
420    ) -> std::result::Result<CallToolResult, McpError> {
421        self.execute_background_impl(command, log_path, exec::BackgroundPrivilege::Normal)
422            .await
423    }
424
425    /// Execute a command with sudo (used by sudo_shell tool)
426    async fn execute_sudo_command_with_timeout(
427        &self,
428        command: &str,
429        timeout: Duration,
430    ) -> std::result::Result<CallToolResult, McpError> {
431        debug!(
432            "sudo_shell tool called: cmd_len={}, background=false, sudo=true, timeout_ms={}",
433            command.len(),
434            timeout.as_millis()
435        );
436
437        // Sanitize the command
438        let sanitized = match self.sanitize_or_tool_error(command) {
439            Ok(cmd) => cmd,
440            Err(result) => return Ok(result),
441        };
442
443        // Wrap the command with sudo
444        let sudo_password = self.connection.get_sudo_password();
445        let wrapped_command = wrap_sudo_command(&sanitized, sudo_password);
446        debug!(
447            "Wrapped sudo command (password hidden): sudo -n sh -c '...' or printf '...' | sudo ..."
448        );
449
450        if let Err(e) = self.connection.ensure_connected().await {
451            error!(error = ?e, "Failed to ensure SSH connection");
452            return Ok(CallToolResult::error(vec![Content::text(e.to_string())]));
453        }
454
455        let detach_mode = match self.determine_detach_mode().await {
456            Ok(mode) => mode,
457            Err(e) => {
458                debug!(error = ?e, "detach-mode probe failed; falling back to direct sudo foreground exec");
459                DetachMode::DirectOnly
460            }
461        };
462        if detach_mode == DetachMode::DirectOnly {
463            match self
464                .connection
465                .exec_command(&wrapped_command, timeout)
466                .await
467            {
468                Ok(output) => Ok(Self::calltool_from_command_output(output)),
469                Err(e) => {
470                    error!(error = ?e, "Sudo command execution failed");
471                    let mut msg = format!("Error: {}", e);
472                    if matches!(e, SshMcpError::Timeout(_)) {
473                        msg.push_str("\nHint: background detach is not supported on this target; rerun with background=true or a larger timeout_ms.");
474                    }
475                    Ok(CallToolResult::error(vec![Content::text(msg)]))
476                }
477            }
478        } else {
479            self.execute_detachable_foreground_impl(
480                detach_mode,
481                &wrapped_command,
482                &format!("sudo {sanitized}"),
483                timeout,
484            )
485            .await
486        }
487    }
488
489    async fn execute_sudo_command(
490        &self,
491        command: &str,
492    ) -> std::result::Result<CallToolResult, McpError> {
493        self.execute_sudo_command_with_timeout(command, self.timeout)
494            .await
495    }
496
497    async fn execute_background_sudo_command(
498        &self,
499        command: &str,
500        log_path: Option<&str>,
501    ) -> std::result::Result<CallToolResult, McpError> {
502        let sudo_password = self.connection.get_sudo_password();
503        self.execute_background_impl(
504            command,
505            log_path,
506            exec::BackgroundPrivilege::Sudo {
507                password: sudo_password,
508            },
509        )
510        .await
511    }
512
513    fn sanitize_or_tool_error(&self, command: &str) -> std::result::Result<String, CallToolResult> {
514        sanitize_command(command, self.max_chars).map_err(|e| {
515            error!(error = ?e, "Command sanitization failed");
516            CallToolResult::error(vec![Content::text(format!("Error: {}", e))])
517        })
518    }
519
520    fn calltool_from_command_output(output: CommandOutput) -> CallToolResult {
521        // Combine stdout and stderr for the response
522        let mut result_text = output.stdout;
523        if !output.stderr.is_empty() {
524            if !result_text.is_empty() {
525                result_text.push_str("\n--- stderr ---\n");
526            }
527            result_text.push_str(&output.stderr);
528        }
529
530        // Check for error exit code.
531        // exit_code=None means the SSH channel was torn down without delivering
532        // an exit status or exit signal — treat as error, not success.
533        if output.exit_code.map(|code| code != 0).unwrap_or(true) {
534            CallToolResult::error(vec![Content::text(result_text)])
535        } else {
536            CallToolResult::success(vec![Content::text(result_text)])
537        }
538    }
539
540    /// Build shell tool definition (compact)
541    fn shell_tool() -> Tool {
542        tools::shell_tool()
543    }
544
545    /// Build sudo_shell tool definition (compact)
546    fn sudo_shell_tool() -> Tool {
547        tools::sudo_shell_tool()
548    }
549
550    /// Build transfer tool definition (compact)
551    fn transfer_tool() -> Tool {
552        tools::transfer_tool()
553    }
554
555    /// Build check_process tool definition
556    fn check_process_tool() -> Tool {
557        tools::check_process_tool()
558    }
559
560    /// Build read tool definition
561    fn read_file_tool() -> Tool {
562        tools::read_file_tool()
563    }
564
565    /// Build apply_patch tool definition
566    fn apply_patch_tool() -> Tool {
567        tools::apply_patch_tool()
568    }
569
570    /// Get extended documentation for a tool by name
571    ///
572    /// Returns the full documentation text that was removed from compact tool definitions
573    /// to save tokens in the MCP protocol.
574    pub fn get_tool_documentation(tool_name: &str) -> Option<&'static str> {
575        tools::get_tool_documentation(tool_name)
576    }
577
578    /// Resolve timeout duration from optional milliseconds, falling back to server default.
579    fn resolve_timeout(&self, timeout_ms: Option<u64>) -> Duration {
580        timeout_ms
581            .map(Duration::from_millis)
582            .unwrap_or(self.timeout)
583    }
584
585    /// Parse tool parameters from JSON with standardized error handling.
586    fn parse_tool_params<T: serde::de::DeserializeOwned>(
587        &self,
588        args: serde_json::Map<String, serde_json::Value>,
589        tool_name: &str,
590    ) -> std::result::Result<T, McpError> {
591        serde_json::from_value(serde_json::Value::Object(args))
592            .map_err(|e| McpError::invalid_params(format!("invalid {tool_name} params: {e}"), None))
593    }
594
595    /// Execute transfer tool with connection management and JSON serialization.
596    async fn execute_transfer(
597        &self,
598        params: TransferParams,
599        verbose: bool,
600    ) -> std::result::Result<CallToolResult, McpError> {
601        let timeout = self.resolve_timeout(params.timeout_ms);
602        let key_path = self.config.key.clone();
603
604        // Ensure connection is established (so errors are deterministic).
605        if let Err(e) = self.connection.ensure_connected().await {
606            let resp = crate::transfer::TransferResponse::error(
607                params,
608                self.transfer.local_root(),
609                &e.to_string(),
610            );
611            let body = resp
612                .to_json(verbose)
613                .unwrap_or_else(|_| "{\"ok\":false,\"error\":\"serialization_error\"}".to_string());
614            return Ok(CallToolResult::success(vec![Content::text(body)]));
615        }
616
617        let resp = self
618            .transfer
619            .run(
620                &self.connection,
621                params,
622                TransferRunContext {
623                    timeout,
624                    ssh: TransferSshOptions {
625                        host: self.config.host.clone(),
626                        port: self.config.port,
627                        user: self.config.user.clone(),
628                        key_path,
629                        host_key_checking: self.config.strict_host_key_checking,
630                        known_hosts: self.config.known_hosts.clone(),
631                    },
632                },
633            )
634            .await;
635        let body = resp
636            .to_json(verbose)
637            .unwrap_or_else(|_| "{\"ok\":false,\"error\":\"serialization_error\"}".to_string());
638        Ok(CallToolResult::success(vec![Content::text(body)]))
639    }
640}
641
642impl ServerHandler for SshMcpServer {
643    /// Return server information
644    fn get_info(&self) -> ServerInfo {
645        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
646            .with_protocol_version(ProtocolVersion::LATEST)
647            .with_server_info(Implementation::from_build_env())
648            .with_instructions(format!(
649                "SSH MCP Server v{} - Execute commands on {}@{}:{}",
650                env!("CARGO_PKG_VERSION"),
651                self.config.user,
652                self.config.host,
653                self.config.port,
654            ))
655    }
656
657    /// List available tools
658    async fn list_tools(
659        &self,
660        _request: Option<PaginatedRequestParams>,
661        _context: RequestContext<RoleServer>,
662    ) -> std::result::Result<ListToolsResult, McpError> {
663        debug!("list_tools called");
664
665        let mut tools = vec![Self::shell_tool()];
666
667        // Docs/expected order: shell, (optional) sudo_shell, check_process, transfer, read, apply_patch.
668        if !self.config.disable_sudo {
669            tools.push(Self::sudo_shell_tool());
670        }
671        tools.push(Self::check_process_tool());
672        tools.push(Self::transfer_tool());
673        tools.push(Self::read_file_tool());
674        tools.push(Self::apply_patch_tool());
675
676        Ok(ListToolsResult {
677            tools,
678            next_cursor: None,
679            meta: Default::default(),
680        })
681    }
682
683    /// Call a tool
684    async fn call_tool(
685        &self,
686        request: CallToolRequestParams,
687        _context: RequestContext<RoleServer>,
688    ) -> std::result::Result<CallToolResult, McpError> {
689        let tool_name: &str = request.name.as_ref();
690        debug!("call_tool called: {:?}", tool_name);
691
692        let args = request.arguments.unwrap_or_default();
693
694        // Route to the appropriate tool
695        match tool_name {
696            "shell" => {
697                let parsed = self.parse_common_tool_args(&args)?;
698                let timeout = self.resolve_timeout(parsed.timeout_ms);
699
700                if parsed.background {
701                    self.execute_background_command(&parsed.command, parsed.log_path.as_deref())
702                        .await
703                } else {
704                    self.execute_command_with_timeout(&parsed.command, timeout)
705                        .await
706                }
707            }
708            "sudo_shell" => {
709                if self.config.disable_sudo {
710                    return Err(McpError::invalid_params(
711                        "sudo_shell tool is disabled",
712                        None,
713                    ));
714                }
715
716                let parsed = self.parse_common_tool_args(&args)?;
717                let timeout = self.resolve_timeout(parsed.timeout_ms);
718
719                if parsed.background {
720                    self.execute_background_sudo_command(
721                        &parsed.command,
722                        parsed.log_path.as_deref(),
723                    )
724                    .await
725                } else {
726                    self.execute_sudo_command_with_timeout(&parsed.command, timeout)
727                        .await
728                }
729            }
730            "transfer" => {
731                let params: TransferParams = self.parse_tool_params(args, "transfer")?;
732                let verbose = params.verbose;
733                self.execute_transfer(params, verbose).await
734            }
735            "check_process" => {
736                let params: CheckProcessParams = self.parse_tool_params(args, "check_process")?;
737                self.execute_check_process(params).await
738            }
739            "read" => {
740                let params: ReadFileParams = self.parse_tool_params(args, "read")?;
741                self.execute_read_file(params).await
742            }
743            "apply_patch" => {
744                let params: ApplyPatchParams = self.parse_tool_params(args, "apply_patch")?;
745                self.execute_apply_patch(
746                    params,
747                    crate::server::handlers::file_edit_common::FileEditFaultInjection::None,
748                )
749                .await
750            }
751            _ => Err(McpError::invalid_params(
752                format!("Unknown tool: {}", tool_name),
753                None,
754            )),
755        }
756    }
757}
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762    use crate::background::response::{
763        BACKGROUND_JSON_SNIPPET_LIMIT_CHARS, background_json_err, background_json_timeout,
764    };
765    use crate::background::wrapper::remote_job_log_path;
766    use crate::server::validation::common::validate_read_file_path;
767    use crate::server::validation::read_file::sanitize_read_file_stderr_snippet;
768
769    fn extract_text_from_result(result: &CallToolResult) -> String {
770        result
771            .content
772            .iter()
773            .filter_map(|c| c.raw.as_text().map(|text| text.text.clone()))
774            .collect::<Vec<_>>()
775            .join("\n")
776    }
777
778    // Note: Real tests would require a mock SSH server or testcontainers
779    // These are placeholder tests
780
781    #[test]
782    fn test_server_info() {
783        // Verify the package version is defined
784        assert!(!env!("CARGO_PKG_VERSION").is_empty());
785    }
786
787    #[test]
788    fn test_shell_tool_definition() {
789        let tool = SshMcpServer::shell_tool();
790        assert_eq!(tool.name.as_ref(), "shell");
791        assert!(tool.description.is_some());
792    }
793
794    #[test]
795    fn test_sudo_shell_tool_definition() {
796        let tool = SshMcpServer::sudo_shell_tool();
797        assert_eq!(tool.name.as_ref(), "sudo_shell");
798        assert!(tool.description.is_some());
799    }
800
801    #[test]
802    fn test_read_file_tool_definition() {
803        let tool = SshMcpServer::read_file_tool();
804        assert_eq!(tool.name.as_ref(), "read");
805        assert!(tool.description.is_some());
806    }
807
808    #[test]
809    fn test_apply_patch_tool_definition() {
810        let tool = SshMcpServer::apply_patch_tool();
811        assert_eq!(tool.name.as_ref(), "apply_patch");
812        assert!(tool.description.is_some());
813    }
814
815    #[test]
816    fn test_build_background_wrapper_full_escapes_single_quotes_in_user_command() {
817        let remote_log = remote_job_log_path("job-1");
818        let script =
819            build_background_wrapper_script_full("job-1", "echo 'hello world'", &remote_log);
820        assert!(script.contains("exec sh -lc 'set +m; echo '\"'\"'hello world'\"'\"''"));
821    }
822
823    #[test]
824    fn test_build_background_wrapper_portable_is_busybox_friendly() {
825        let remote_log = remote_job_log_path("job-1");
826        let script = build_background_wrapper_script_portable("job-1", "echo test", &remote_log);
827        assert!(!script.contains("dirname --"));
828        assert!(!script.contains("mkdir -p --"));
829        assert!(!script.contains("sh -lc"));
830        assert!(script.contains("exec sh -c"));
831        assert!(!script.contains("nohup"));
832    }
833
834    #[test]
835    fn test_background_wrappers_emit_markers_and_exec() {
836        let remote_log = remote_job_log_path("job-1");
837
838        let full = build_background_wrapper_script_full("job-1", "echo test", &remote_log);
839        assert!(full.contains("__SSH_MCP_JOB_ID=job-1"));
840        assert!(full.contains("__SSH_MCP_PID=$$"));
841        assert!(full.contains("__SSH_MCP_LOG=$LOG"));
842        assert!(full.contains("exec sh -lc"));
843
844        let portable = build_background_wrapper_script_portable("job-1", "echo test", &remote_log);
845        assert!(portable.contains("__SSH_MCP_JOB_ID=job-1"));
846        assert!(portable.contains("__SSH_MCP_PID=$$"));
847        assert!(portable.contains("__SSH_MCP_LOG=$LOG"));
848        assert!(portable.contains("exec sh -c"));
849    }
850
851    #[test]
852    fn test_background_wrappers_do_not_redirect_remote_output() {
853        let remote_log = remote_job_log_path("job-1");
854        let full = build_background_wrapper_script_full("job-1", "echo test", &remote_log);
855        assert!(!full.contains(">$LOG"));
856        assert!(!full.contains("2>&1"));
857        assert!(!full.contains("$EXIT"));
858        assert!(!full.contains("nohup"));
859
860        let portable = build_background_wrapper_script_portable("job-1", "echo test", &remote_log);
861        assert!(!portable.contains(">$LOG"));
862        assert!(!portable.contains("2>&1"));
863        assert!(!portable.contains("$EXIT"));
864        assert!(!portable.contains("nohup"));
865    }
866
867    #[test]
868    fn test_validate_background_log_path_rejects_leading_dash() {
869        let err =
870            validate_background_log_path(Path::new("/tmp/ssh-mcp"), "-not-a-path").unwrap_err();
871        assert!(err.contains("start with '-'") || err.contains("start with"));
872    }
873
874    #[test]
875    fn test_validate_background_log_path_rejects_newlines() {
876        assert!(
877            validate_background_log_path(Path::new("/tmp/ssh-mcp"), "/tmp/x\nrm -rf /").is_err()
878        );
879        assert!(
880            validate_background_log_path(Path::new("/tmp/ssh-mcp"), "/tmp/x\rrm -rf /").is_err()
881        );
882    }
883
884    #[test]
885    fn test_validate_read_file_path_requires_absolute() {
886        let err = validate_read_file_path("relative/path").unwrap_err();
887        assert!(err.contains("absolute"));
888    }
889
890    #[test]
891    fn test_validate_read_file_path_rejects_trailing_slash() {
892        let err = validate_read_file_path("/etc/").unwrap_err();
893        assert!(err.contains("must not end with '/'"));
894    }
895
896    #[test]
897    fn test_resolve_read_file_max_bytes_uses_token_limit() {
898        assert_eq!(
899            resolve_read_file_max_bytes(Some(12_000)),
900            12_000 * READ_FILE_BYTES_PER_TOKEN
901        );
902    }
903
904    #[test]
905    fn test_resolve_read_file_max_bytes_none_uses_hard_cap() {
906        assert_eq!(resolve_read_file_max_bytes(None), READ_FILE_HARD_MAX_BYTES);
907    }
908
909    #[test]
910    fn test_resolve_read_file_max_bytes_applies_hard_cap() {
911        let very_large_tokens = READ_FILE_HARD_MAX_BYTES;
912        assert_eq!(
913            resolve_read_file_max_bytes(Some(very_large_tokens)),
914            READ_FILE_HARD_MAX_BYTES
915        );
916    }
917
918    #[test]
919    fn test_estimate_tokens_from_bytes_rounds_up() {
920        assert_eq!(estimate_tokens_from_bytes(0), 0);
921        assert_eq!(estimate_tokens_from_bytes(1), 1);
922        assert_eq!(estimate_tokens_from_bytes(4), 1);
923        assert_eq!(estimate_tokens_from_bytes(5), 2);
924    }
925
926    #[test]
927    fn test_resolve_read_file_line_limit_defaults_to_preview_window() {
928        let preview = resolve_read_file_line_limit(ReadFileMode::Preview, None)
929            .expect("preview lines should resolve");
930        assert_eq!(preview, Some(READ_FILE_DEFAULT_PREVIEW_LINES));
931
932        let head = resolve_read_file_line_limit(ReadFileMode::Head, None)
933            .expect("head lines should resolve");
934        assert_eq!(head, Some(READ_FILE_DEFAULT_PREVIEW_LINES));
935
936        let tail = resolve_read_file_line_limit(ReadFileMode::Tail, None)
937            .expect("tail lines should resolve");
938        assert_eq!(tail, Some(READ_FILE_DEFAULT_PREVIEW_LINES));
939    }
940
941    #[test]
942    fn test_resolve_read_file_line_limit_for_full_ignores_lines() {
943        let full = resolve_read_file_line_limit(ReadFileMode::Full, Some(123))
944            .expect("full mode should ignore lines");
945        assert_eq!(full, None);
946    }
947
948    #[test]
949    fn test_resolve_read_file_line_limit_rejects_zero() {
950        let err = resolve_read_file_line_limit(ReadFileMode::Head, Some(0)).unwrap_err();
951        assert!(err.contains("positive"));
952    }
953
954    #[test]
955    fn test_resolve_read_file_line_limit_rejects_too_large() {
956        let err =
957            resolve_read_file_line_limit(ReadFileMode::Tail, Some(READ_FILE_MAX_LINE_WINDOW + 1))
958                .unwrap_err();
959        assert!(err.contains("<="));
960    }
961
962    #[test]
963    fn test_sanitize_read_file_stderr_snippet_normalizes_whitespace_and_controls() {
964        let stderr = "line1\nline2\t\u{0007}bad\rline3";
965        let snippet = sanitize_read_file_stderr_snippet(stderr)
966            .expect("snippet should be present for non-empty stderr");
967        assert_eq!(snippet, "line1 line2 bad line3");
968    }
969
970    #[test]
971    fn test_background_json_err_sets_truncation_flag_and_hint() {
972        let long_error = "e".repeat(BACKGROUND_JSON_SNIPPET_LIMIT_CHARS + 10);
973        let long_stderr = "s".repeat(BACKGROUND_JSON_SNIPPET_LIMIT_CHARS + 10);
974
975        let result =
976            background_json_err("job-1", "/tmp/ssh-mcp/job-1.log", &long_error, &long_stderr);
977        let text = extract_text_from_result(&result);
978
979        let value: serde_json::Value =
980            serde_json::from_str(text.trim()).expect("background_json_err should return JSON");
981
982        assert_eq!(value.get("ok").and_then(|v| v.as_bool()), Some(false));
983        assert_eq!(
984            value.get("background").and_then(|v| v.as_bool()),
985            Some(true)
986        );
987        assert_eq!(value.get("truncated").and_then(|v| v.as_bool()), Some(true));
988
989        let fields = value
990            .get("truncated_fields")
991            .expect("expected truncated_fields");
992        assert_eq!(fields.get("error").and_then(|v| v.as_bool()), Some(true));
993        assert_eq!(fields.get("stderr").and_then(|v| v.as_bool()), Some(true));
994
995        let hint = value
996            .get("hint")
997            .and_then(|v| v.as_str())
998            .expect("expected hint when truncated");
999        assert!(
1000            hint.contains("check_process") && hint.contains("job_id=job-1"),
1001            "hint should point to check_process job_id; got: '{hint}'"
1002        );
1003
1004        let error_snippet = value
1005            .get("error")
1006            .and_then(|v| v.as_str())
1007            .expect("expected error field");
1008        assert_eq!(
1009            error_snippet.chars().count(),
1010            BACKGROUND_JSON_SNIPPET_LIMIT_CHARS
1011        );
1012        let stderr_snippet = value
1013            .get("stderr")
1014            .and_then(|v| v.as_str())
1015            .expect("expected stderr field");
1016        assert_eq!(
1017            stderr_snippet.chars().count(),
1018            BACKGROUND_JSON_SNIPPET_LIMIT_CHARS
1019        );
1020    }
1021
1022    #[test]
1023    fn test_background_json_timeout_hint_contains_pid_and_check_process_tool() {
1024        let result = background_json_timeout(
1025            "job-42",
1026            4242,
1027            "/tmp/ssh-mcp/local.log",
1028            &crate::background::response::BackgroundTimeoutSnapshot {
1029                state: "running",
1030                still_running: true,
1031                exit_code: None,
1032                state_reason: None,
1033                elapsed_time: "00:01",
1034                log_exists: true,
1035                log_tail: "tail line",
1036                tail_lines_used: 50,
1037            },
1038        );
1039        let text = extract_text_from_result(&result);
1040
1041        let value: serde_json::Value =
1042            serde_json::from_str(text.trim()).expect("background_json_timeout should return JSON");
1043
1044        assert_eq!(value.get("ok").and_then(|v| v.as_bool()), Some(false));
1045        assert_eq!(value.get("timeout").and_then(|v| v.as_bool()), Some(true));
1046        assert_eq!(
1047            value.get("background").and_then(|v| v.as_bool()),
1048            Some(true)
1049        );
1050        assert_eq!(
1051            value.get("still_running").and_then(|v| v.as_bool()),
1052            Some(true)
1053        );
1054        assert_eq!(value.get("state").and_then(|v| v.as_str()), Some("running"));
1055        assert_eq!(
1056            value.get("tail_lines_used").and_then(|v| v.as_u64()),
1057            Some(50)
1058        );
1059        assert_eq!(
1060            value.get("elapsed_time").and_then(|v| v.as_str()),
1061            Some("00:01")
1062        );
1063        assert_eq!(
1064            value.get("log_tail").and_then(|v| v.as_str()),
1065            Some("tail line")
1066        );
1067
1068        let hint = value
1069            .get("hint")
1070            .and_then(|v| v.as_str())
1071            .expect("expected hint field");
1072
1073        // Hint should contain the actual job_id value
1074        assert!(
1075            hint.contains("job_id=job-42"),
1076            "hint should contain the actual job_id value; got: '{hint}'"
1077        );
1078        // Hint should mention the check_process tool
1079        assert!(
1080            hint.contains("check_process"),
1081            "hint should mention check_process tool; got: '{hint}'"
1082        );
1083        // Hint should warn against restarting
1084        assert!(
1085            hint.contains("DO NOT restart"),
1086            "hint should warn against restarting; got: '{hint}'"
1087        );
1088        // Hint should use TIMEOUT_RECOVERY prefix
1089        assert!(
1090            hint.contains("TIMEOUT_RECOVERY"),
1091            "hint should start with TIMEOUT_RECOVERY; got: '{hint}'"
1092        );
1093        // Hint should NOT contain old placeholders
1094        assert!(
1095            !hint.contains("<pid>"),
1096            "hint should not contain <pid> placeholder; got: '{hint}'"
1097        );
1098        assert!(
1099            !hint.contains("<log_path>"),
1100            "hint should not contain <log_path> placeholder; got: '{hint}'"
1101        );
1102    }
1103
1104    #[test]
1105    fn test_tool_documentation_available() {
1106        // Verify that extended documentation is available for all tools
1107        assert!(SshMcpServer::get_tool_documentation("shell").is_some());
1108        assert!(SshMcpServer::get_tool_documentation("sudo_shell").is_some());
1109        assert!(SshMcpServer::get_tool_documentation("transfer").is_some());
1110        assert!(SshMcpServer::get_tool_documentation("read").is_some());
1111        assert!(SshMcpServer::get_tool_documentation("apply_patch").is_some());
1112        assert!(SshMcpServer::get_tool_documentation("write-file").is_none());
1113        assert!(SshMcpServer::get_tool_documentation("replace-in-file").is_none());
1114        assert!(SshMcpServer::get_tool_documentation("unknown").is_none());
1115    }
1116
1117    #[test]
1118    fn test_shell_documentation_content() {
1119        let docs = SshMcpServer::get_tool_documentation("shell").unwrap();
1120        assert!(docs.contains("SHELL TOOL"));
1121        assert!(docs.contains("PARAMETERS:"));
1122        assert!(docs.contains("BACKGROUND MODE:"));
1123        assert!(docs.contains("command"));
1124        assert!(docs.contains("background"));
1125        assert!(docs.contains("still_running"));
1126    }
1127
1128    #[test]
1129    fn test_sudo_shell_documentation_content() {
1130        let docs = SshMcpServer::get_tool_documentation("sudo_shell").unwrap();
1131        assert!(docs.contains("SUDO_SHELL TOOL"));
1132        assert!(docs.contains("sudo"));
1133    }
1134
1135    #[test]
1136    fn test_transfer_documentation_content() {
1137        let docs = SshMcpServer::get_tool_documentation("transfer").unwrap();
1138        assert!(docs.contains("TRANSFER TOOL"));
1139        assert!(docs.contains("put"));
1140        assert!(docs.contains("get"));
1141        assert!(docs.contains("TRANSPORTS:"));
1142    }
1143
1144    #[test]
1145    fn test_read_file_documentation_content() {
1146        let docs = SshMcpServer::get_tool_documentation("read").unwrap();
1147        assert!(docs.contains("READ TOOL"));
1148        assert!(docs.contains("remote_path"));
1149        assert!(docs.contains("mode"));
1150        assert!(docs.contains("UTF-8"));
1151    }
1152
1153    #[test]
1154    fn test_apply_patch_documentation_content() {
1155        let docs = SshMcpServer::get_tool_documentation("apply_patch").unwrap();
1156        assert!(docs.contains("APPLY_PATCH TOOL"));
1157        assert!(docs.contains("Add File"));
1158        assert!(docs.contains("Delete File"));
1159    }
1160
1161    #[test]
1162    fn test_compact_tool_descriptions() {
1163        // Verify that tool descriptions are compact (not verbose)
1164        let shell = SshMcpServer::shell_tool();
1165        let sudo_shell = SshMcpServer::sudo_shell_tool();
1166        let transfer = SshMcpServer::transfer_tool();
1167        let read_file = SshMcpServer::read_file_tool();
1168        let apply_patch = SshMcpServer::apply_patch_tool();
1169
1170        // Descriptions should be present but concise (under 100 chars)
1171        if let Some(desc) = shell.description {
1172            assert!(
1173                desc.len() < 100,
1174                "shell description too long: {} chars",
1175                desc.len()
1176            );
1177        }
1178        if let Some(desc) = sudo_shell.description {
1179            assert!(
1180                desc.len() < 100,
1181                "sudo_shell description too long: {} chars",
1182                desc.len()
1183            );
1184        }
1185        if let Some(desc) = transfer.description {
1186            assert!(
1187                desc.len() < 100,
1188                "transfer description too long: {} chars",
1189                desc.len()
1190            );
1191        }
1192        if let Some(desc) = read_file.description {
1193            assert!(
1194                desc.len() < 100,
1195                "read description too long: {} chars",
1196                desc.len()
1197            );
1198        }
1199        if let Some(desc) = apply_patch.description {
1200            assert!(
1201                desc.len() < 100,
1202                "apply_patch description too long: {} chars",
1203                desc.len()
1204            );
1205        }
1206    }
1207}