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::{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::job::NewRunningJob;
22use crate::background::{JobRegistry, JobState, LocalLogSpooler, SharedJobState};
23use crate::config::Config;
24use crate::error::{Result, SshMcpError};
25#[cfg(unix)]
26use crate::platform::O_NOFOLLOW_FLAG;
27use crate::server::handlers::file_edit_common::{FileEditFaultInjection, FileEditPrivilege};
28#[cfg(test)]
29use crate::server::validation::validate_background_log_path;
30use crate::ssh::{
31    CommandOutput, SshConfig, SshConnectionManager, sanitize_command, wrap_sudo_command,
32};
33use crate::tools::ApplyPatchParams;
34use crate::transfer::{TransferEngine, TransferParams, TransferRunContext, TransferSshOptions};
35
36mod args;
37mod exec;
38mod handlers;
39mod testing;
40mod tools;
41mod validation;
42
43const BACKGROUND_START_TIMEOUT: Duration = Duration::from_secs(20);
44
45const JOB_COMPLETED_RETENTION: Duration = Duration::from_secs(60 * 60);
46
47static JOB_COUNTER: AtomicU64 = AtomicU64::new(0);
48
49fn make_job_id() -> String {
50    let counter = JOB_COUNTER.fetch_add(1, Ordering::Relaxed);
51    let epoch_ms = SystemTime::now()
52        .duration_since(UNIX_EPOCH)
53        .map(|d| d.as_millis())
54        .unwrap_or(0);
55    format!("{}-{}", epoch_ms, counter)
56}
57
58/// SSH MCP Server
59///
60/// The main server implementation that provides MCP tools for remote SSH
61/// command execution.
62#[derive(Clone)]
63pub struct SshMcpServer {
64    /// Server configuration
65    config: Config,
66
67    /// SSH connection manager
68    connection: Arc<SshConnectionManager>,
69
70    /// Command execution timeout
71    timeout: Duration,
72
73    /// Maximum command length
74    max_chars: Option<usize>,
75
76    spooler: Arc<LocalLogSpooler>,
77    job_registry: Arc<JobRegistry>,
78
79    transfer: TransferEngine,
80}
81
82impl SshMcpServer {
83    /// Create a new SSH MCP Server
84    ///
85    /// This sets up the SSH connection manager based on the provided configuration.
86    /// Connection is not established until a tool is actually used.
87    pub async fn new(config: Config) -> Result<Self> {
88        Self::new_with_spool_dir(config, None).await
89    }
90
91    /// Create a new SSH MCP Server with an optional local spool directory.
92    pub async fn new_with_spool_dir(config: Config, spool_dir: Option<PathBuf>) -> Result<Self> {
93        let local_root = std::env::current_dir()?;
94
95        let spooler = Arc::new(resolve_local_spooler(spool_dir)?);
96        spooler.ensure_dir().await.map_err(|e| {
97            SshMcpError::Config(format!(
98                "failed to initialize local log spool dir {}: {e}",
99                spooler.base_dir().display()
100            ))
101        })?;
102        let job_registry = Arc::new(JobRegistry::new(JOB_COMPLETED_RETENTION));
103
104        // Build SSH configuration
105        let mut ssh_config = SshConfig::new(&config.host, &config.user).with_port(config.port);
106
107        // Add authentication
108        if let Some(ref password) = config.password {
109            ssh_config = ssh_config.with_password(password);
110        }
111
112        if let Some(ref key_path) = config.key {
113            // Read the key file
114            let key_content = tokio::fs::read_to_string(key_path)
115                .await
116                .map_err(SshMcpError::Io)?;
117            ssh_config = ssh_config.with_private_key(&key_content);
118        }
119
120        // Add elevation passwords if provided
121        if let Some(ref su_password) = config.su_password {
122            ssh_config = ssh_config.with_su_password(su_password);
123        }
124
125        if let Some(ref sudo_password) = config.sudo_password {
126            ssh_config = ssh_config.with_sudo_password(sudo_password);
127        }
128
129        // Add keepalive settings for human-like connection persistence
130        ssh_config = ssh_config
131            .with_keepalive_interval(config.keepalive_interval)
132            .with_keepalive_max(config.keepalive_max);
133
134        // Add reconnect and health probe settings
135        ssh_config = ssh_config
136            .with_reconnect_retries(config.reconnect_retries)
137            .with_reconnect_backoff_ms(config.reconnect_backoff_ms)
138            .with_health_probe_timeout_ms(config.health_probe_timeout_ms);
139
140        // Add host key verification settings
141        ssh_config = ssh_config
142            .with_host_key_checking(config.strict_host_key_checking)
143            .with_known_hosts(config.known_hosts.clone());
144
145        // Add output token limit for OOM protection
146        ssh_config = ssh_config.with_max_output_tokens(config.max_output_tokens);
147
148        // Create connection manager
149        let connection = Arc::new(SshConnectionManager::new(ssh_config).await);
150
151        let timeout = Duration::from_millis(config.timeout_ms);
152        let max_chars = config.max_chars;
153
154        Ok(Self {
155            config,
156            connection,
157            timeout,
158            max_chars,
159            spooler,
160            job_registry,
161            transfer: TransferEngine::new(local_root),
162        })
163    }
164
165    fn connection_id(&self) -> String {
166        format!(
167            "{}@{}:{}",
168            self.config.user, self.config.host, self.config.port
169        )
170    }
171
172    fn default_local_log_path(
173        &self,
174        job_id: &str,
175    ) -> std::result::Result<(PathBuf, String), String> {
176        let path = self
177            .spooler
178            .log_path_for(job_id)
179            .map_err(|e| format!("failed to generate local log path for job_id='{job_id}': {e}"))?;
180        let path_str = path.to_string_lossy().to_string();
181        Ok((path, path_str))
182    }
183
184    async fn ensure_local_log_file(&self, log_path: &Path) -> std::result::Result<(), SshMcpError> {
185        self.spooler.ensure_dir().await.map_err(|e| {
186            SshMcpError::Config(format!(
187                "failed to ensure local log spool dir {}: {e}",
188                self.spooler.base_dir().display()
189            ))
190        })?;
191
192        if log_path.parent() != Some(self.spooler.base_dir()) {
193            return Err(SshMcpError::InvalidParams(format!(
194                "log_path must be directly under {}",
195                self.spooler.base_dir().display()
196            )));
197        }
198
199        match tokio::fs::symlink_metadata(log_path).await {
200            Ok(meta) => {
201                let ft = meta.file_type();
202                if ft.is_symlink() {
203                    return Err(SshMcpError::invalid_params(
204                        "log_path is a symlink (refusing to follow it)",
205                    ));
206                }
207                if !ft.is_file() {
208                    return Err(SshMcpError::invalid_params(
209                        "log_path exists but is not a regular file",
210                    ));
211                }
212            }
213            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
214            Err(e) => return Err(SshMcpError::Io(e)),
215        }
216
217        let mut opts = tokio::fs::OpenOptions::new();
218        opts.write(true).create(true).truncate(true);
219
220        #[cfg(unix)]
221        {
222            opts.custom_flags(O_NOFOLLOW_FLAG);
223        }
224
225        let file = match opts.open(log_path).await {
226            Ok(f) => f,
227            Err(e) => {
228                if let Ok(meta) = tokio::fs::symlink_metadata(log_path).await
229                    && meta.file_type().is_symlink()
230                {
231                    return Err(SshMcpError::invalid_params(
232                        "log_path is a symlink (refusing to follow it)",
233                    ));
234                }
235                return Err(SshMcpError::Io(e));
236            }
237        };
238
239        file.sync_all().await.map_err(SshMcpError::Io)
240    }
241
242    async fn register_running_job(
243        &self,
244        job_id: &str,
245        pid: u32,
246        log_path: PathBuf,
247        command: &str,
248    ) -> SharedJobState {
249        let job = Arc::new(Mutex::new(JobState::new_running(NewRunningJob {
250            job_id: job_id.to_string(),
251            pid,
252            log_path,
253            command: command.to_string(),
254            connection_id: self.connection_id(),
255        })));
256
257        self.job_registry
258            .insert(job_id.to_string(), Arc::clone(&job))
259            .await;
260
261        let persisted = {
262            let guard = job.lock().await;
263            guard.clone()
264        };
265        if let Err(e) = self.spooler.persist_job_state(&persisted).await {
266            warn!(job_id = ?job_id, error = ?e, "failed to persist running job state");
267        }
268
269        job
270    }
271
272    /// Get a reference to the SSH connection manager
273    pub fn connection(&self) -> &Arc<SshConnectionManager> {
274        &self.connection
275    }
276
277    /// Close the server and cleanup resources
278    pub async fn shutdown(&self) {
279        info!("Shutting down SSH MCP Server...");
280        self.connection.close().await;
281    }
282
283    /// Execute a command (used by shell tool)
284    async fn execute_command_with_timeout(
285        &self,
286        command: &str,
287        timeout: Duration,
288    ) -> std::result::Result<CallToolResult, McpError> {
289        debug!(
290            "shell tool called: cmd_len={}, background=false, sudo=false, timeout_ms={}",
291            command.len(),
292            timeout.as_millis()
293        );
294
295        // Sanitize the command
296        let sanitized = match self.sanitize_or_tool_error(command) {
297            Ok(cmd) => cmd,
298            Err(result) => return Ok(result),
299        };
300
301        // Foreground execution is detachable-by-design:
302        // - Start the command on a dedicated SSH channel
303        // - Stream remote stdout/stderr into a local spool file
304        // - If timeout elapses, return JSON with job_id/pid/log_path while the stream continues
305
306        let requires_elevation = self.connection.get_su_password().is_some();
307        if requires_elevation {
308            if let Err(e) = self.connection.ensure_connected().await {
309                error!(error = ?e, "Failed to ensure SSH connection");
310                return Ok(CallToolResult::error(vec![ContentBlock::text(
311                    e.to_string(),
312                )]));
313            }
314
315            if let Err(e) = self.connection.ensure_elevated().await {
316                debug!(error = ?e, "Elevation failed, will run as normal user");
317            }
318        }
319
320        // Ensure connection is established for detached foreground execution path.
321        if !requires_elevation && let Err(e) = self.connection.ensure_connected().await {
322            error!(error = ?e, "Failed to ensure SSH connection");
323            return Ok(CallToolResult::error(vec![ContentBlock::text(
324                e.to_string(),
325            )]));
326        }
327
328        self.execute_detachable_foreground_impl(&sanitized, &sanitized, timeout)
329            .await
330    }
331
332    async fn execute_command(
333        &self,
334        command: &str,
335    ) -> std::result::Result<CallToolResult, McpError> {
336        self.execute_command_with_timeout(command, self.timeout)
337            .await
338    }
339
340    async fn execute_background_command(
341        &self,
342        command: &str,
343        log_path: Option<&str>,
344    ) -> std::result::Result<CallToolResult, McpError> {
345        self.execute_background_impl(command, log_path, exec::BackgroundPrivilege::Normal)
346            .await
347    }
348
349    /// Execute a command with sudo (used by sudo_shell tool)
350    async fn execute_sudo_command_with_timeout(
351        &self,
352        command: &str,
353        timeout: Duration,
354    ) -> std::result::Result<CallToolResult, McpError> {
355        debug!(
356            "sudo_shell tool called: cmd_len={}, background=false, sudo=true, timeout_ms={}",
357            command.len(),
358            timeout.as_millis()
359        );
360
361        // Sanitize the command
362        let sanitized = match self.sanitize_or_tool_error(command) {
363            Ok(cmd) => cmd,
364            Err(result) => return Ok(result),
365        };
366
367        // Wrap the command with sudo
368        let sudo_password = self.connection.get_sudo_password();
369        let wrapped_command = wrap_sudo_command(&sanitized, sudo_password);
370        debug!(
371            "Wrapped sudo command (password hidden): sudo -n sh -c '...' or printf '...' | sudo ..."
372        );
373
374        if let Err(e) = self.connection.ensure_connected().await {
375            error!(error = ?e, "Failed to ensure SSH connection");
376            return Ok(CallToolResult::error(vec![ContentBlock::text(
377                e.to_string(),
378            )]));
379        }
380
381        self.execute_detachable_foreground_impl(
382            &wrapped_command,
383            &format!("sudo {sanitized}"),
384            timeout,
385        )
386        .await
387    }
388
389    async fn execute_sudo_command(
390        &self,
391        command: &str,
392    ) -> std::result::Result<CallToolResult, McpError> {
393        self.execute_sudo_command_with_timeout(command, self.timeout)
394            .await
395    }
396
397    async fn execute_background_sudo_command(
398        &self,
399        command: &str,
400        log_path: Option<&str>,
401    ) -> std::result::Result<CallToolResult, McpError> {
402        let sudo_password = self.connection.get_sudo_password();
403        self.execute_background_impl(
404            command,
405            log_path,
406            exec::BackgroundPrivilege::Sudo {
407                password: sudo_password,
408            },
409        )
410        .await
411    }
412
413    fn sanitize_or_tool_error(&self, command: &str) -> std::result::Result<String, CallToolResult> {
414        sanitize_command(command, self.max_chars).map_err(|e| {
415            error!(error = ?e, "Command sanitization failed");
416            CallToolResult::error(vec![ContentBlock::text(format!("Error: {}", e))])
417        })
418    }
419
420    fn calltool_from_command_output(output: CommandOutput) -> CallToolResult {
421        // Combine stdout and stderr for the response
422        let mut result_text = output.stdout;
423        if !output.stderr.is_empty() {
424            if !result_text.is_empty() {
425                result_text.push_str("\n--- stderr ---\n");
426            }
427            result_text.push_str(&output.stderr);
428        }
429
430        // Check for error exit code.
431        // exit_code=None means the SSH channel was torn down without delivering
432        // an exit status or exit signal — treat as error, not success.
433        if output.exit_code.map(|code| code != 0).unwrap_or(true) {
434            CallToolResult::error(vec![ContentBlock::text(result_text)])
435        } else {
436            CallToolResult::success(vec![ContentBlock::text(result_text)])
437        }
438    }
439
440    /// Build shell tool definition (compact)
441    fn shell_tool() -> Tool {
442        tools::shell_tool()
443    }
444
445    /// Build sudo_shell tool definition (compact)
446    fn sudo_shell_tool() -> Tool {
447        tools::sudo_shell_tool()
448    }
449
450    /// Build transfer tool definition (compact)
451    fn transfer_tool() -> Tool {
452        tools::transfer_tool()
453    }
454
455    /// Build check_process tool definition
456    fn check_process_tool() -> Tool {
457        tools::check_process_tool()
458    }
459
460    /// Build apply_patch tool definition
461    fn apply_patch_tool() -> Tool {
462        tools::apply_patch_tool()
463    }
464
465    /// Build sudo_apply_patch tool definition
466    fn sudo_apply_patch_tool() -> Tool {
467        tools::sudo_apply_patch_tool()
468    }
469
470    /// Resolve timeout duration from optional milliseconds, falling back to server default.
471    fn resolve_timeout(&self, timeout_ms: Option<u64>) -> Duration {
472        timeout_ms
473            .map(Duration::from_millis)
474            .unwrap_or(self.timeout)
475    }
476
477    /// Parse tool parameters from JSON with standardized error handling.
478    fn parse_tool_params<T: serde::de::DeserializeOwned>(
479        &self,
480        args: serde_json::Map<String, serde_json::Value>,
481        tool_name: &str,
482    ) -> std::result::Result<T, McpError> {
483        serde_json::from_value(serde_json::Value::Object(args))
484            .map_err(|e| McpError::invalid_params(format!("invalid {tool_name} params: {e}"), None))
485    }
486
487    /// Execute transfer tool with connection management and JSON serialization.
488    async fn execute_transfer(
489        &self,
490        params: TransferParams,
491        verbose: bool,
492    ) -> std::result::Result<CallToolResult, McpError> {
493        let timeout = self.resolve_timeout(params.timeout_ms);
494        let key_path = self.config.key.clone();
495
496        // Ensure connection is established (so errors are deterministic).
497        if let Err(e) = self.connection.ensure_connected().await {
498            let resp = crate::transfer::TransferResponse::error(
499                params,
500                self.transfer.local_root(),
501                &e.to_string(),
502            );
503            let body = resp
504                .to_json(verbose)
505                .unwrap_or_else(|_| "{\"ok\":false,\"error\":\"serialization_error\"}".to_string());
506            return Ok(CallToolResult::success(vec![ContentBlock::text(body)]));
507        }
508
509        let resp = self
510            .transfer
511            .run(
512                &self.connection,
513                params,
514                TransferRunContext {
515                    timeout,
516                    ssh: TransferSshOptions {
517                        host: self.config.host.clone(),
518                        port: self.config.port,
519                        user: self.config.user.clone(),
520                        key_path,
521                        host_key_checking: self.config.strict_host_key_checking,
522                        known_hosts: self.config.known_hosts.clone(),
523                    },
524                },
525            )
526            .await;
527        let body = resp
528            .to_json(verbose)
529            .unwrap_or_else(|_| "{\"ok\":false,\"error\":\"serialization_error\"}".to_string());
530        Ok(CallToolResult::success(vec![ContentBlock::text(body)]))
531    }
532}
533
534fn resolve_local_spooler(spool_dir: Option<PathBuf>) -> Result<LocalLogSpooler> {
535    match spool_dir {
536        Some(path) if !path.is_absolute() => Err(SshMcpError::Config(format!(
537            "spool directory must be absolute: {}",
538            path.display()
539        ))),
540        Some(path) => Ok(LocalLogSpooler::new(path)),
541        None => Ok(LocalLogSpooler::new_default()),
542    }
543}
544
545impl ServerHandler for SshMcpServer {
546    /// Return server information
547    fn get_info(&self) -> ServerInfo {
548        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
549            .with_protocol_version(ProtocolVersion::LATEST)
550            .with_server_info(Implementation::from_build_env())
551            .with_instructions(format!(
552                "SSH MCP Server v{} - Execute commands on {}@{}:{}",
553                env!("CARGO_PKG_VERSION"),
554                self.config.user,
555                self.config.host,
556                self.config.port,
557            ))
558    }
559
560    /// List available tools
561    async fn list_tools(
562        &self,
563        _request: Option<PaginatedRequestParams>,
564        _context: RequestContext<RoleServer>,
565    ) -> std::result::Result<ListToolsResult, McpError> {
566        debug!("list_tools called");
567
568        let mut tools = vec![Self::shell_tool()];
569
570        // Docs/expected order: shell, optional sudo tools, check_process, transfer, apply_patch.
571        if !self.config.disable_sudo {
572            tools.push(Self::sudo_shell_tool());
573            tools.push(Self::sudo_apply_patch_tool());
574        }
575        tools.push(Self::check_process_tool());
576        tools.push(Self::transfer_tool());
577        tools.push(Self::apply_patch_tool());
578
579        Ok(ListToolsResult {
580            tools,
581            next_cursor: None,
582            meta: Default::default(),
583        })
584    }
585
586    /// Call a tool
587    async fn call_tool(
588        &self,
589        request: CallToolRequestParams,
590        context: RequestContext<RoleServer>,
591    ) -> std::result::Result<CallToolResult, McpError> {
592        let tool_name: &str = request.name.as_ref();
593        debug!("call_tool called: {:?}", tool_name);
594
595        let args = request.arguments.unwrap_or_default();
596
597        // Route to the appropriate tool
598        match tool_name {
599            "shell" => {
600                let parsed = self.parse_common_tool_args(&args)?;
601                let timeout = self.resolve_timeout(parsed.timeout_ms);
602
603                if parsed.background {
604                    self.execute_background_command(&parsed.command, parsed.log_path.as_deref())
605                        .await
606                } else {
607                    self.execute_command_with_timeout(&parsed.command, timeout)
608                        .await
609                }
610            }
611            "sudo_shell" => {
612                if self.config.disable_sudo {
613                    return Err(McpError::invalid_params(
614                        "sudo_shell tool is disabled",
615                        None,
616                    ));
617                }
618
619                let parsed = self.parse_common_tool_args(&args)?;
620                let timeout = self.resolve_timeout(parsed.timeout_ms);
621
622                if parsed.background {
623                    self.execute_background_sudo_command(
624                        &parsed.command,
625                        parsed.log_path.as_deref(),
626                    )
627                    .await
628                } else {
629                    self.execute_sudo_command_with_timeout(&parsed.command, timeout)
630                        .await
631                }
632            }
633            "transfer" => {
634                let params: TransferParams = self.parse_tool_params(args, "transfer")?;
635                let verbose = params.verbose;
636                self.execute_transfer(params, verbose).await
637            }
638            "check_process" => {
639                let params: args::CheckProcessToolArgs =
640                    self.parse_tool_params(args, "check_process")?;
641                self.execute_check_process(params.check, params.wait_for, context.ct.cancelled())
642                    .await
643            }
644            "apply_patch" => {
645                let params: ApplyPatchParams = self.parse_tool_params(args, "apply_patch")?;
646                self.execute_apply_patch(
647                    params,
648                    FileEditFaultInjection::None,
649                    FileEditPrivilege::User,
650                )
651                .await
652            }
653            "sudo_apply_patch" => {
654                if self.config.disable_sudo {
655                    return Err(McpError::invalid_params(
656                        "sudo_apply_patch tool is disabled",
657                        None,
658                    ));
659                }
660
661                let params: ApplyPatchParams = self.parse_tool_params(args, "sudo_apply_patch")?;
662                self.execute_apply_patch(
663                    params,
664                    FileEditFaultInjection::None,
665                    FileEditPrivilege::Sudo,
666                )
667                .await
668            }
669            _ => Err(McpError::invalid_params(
670                format!("Unknown tool: {}", tool_name),
671                None,
672            )),
673        }
674    }
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::background::response::{
681        BACKGROUND_JSON_SNIPPET_LIMIT_CHARS, background_json_err, background_json_timeout,
682    };
683    use crate::background::wrapper::{build_background_wrapper_script, remote_job_log_path};
684
685    fn extract_text_from_result(result: &CallToolResult) -> String {
686        result
687            .content
688            .iter()
689            .filter_map(|c| c.as_text().map(|text| text.text.clone()))
690            .collect::<Vec<_>>()
691            .join("\n")
692    }
693
694    #[test]
695    fn test_server_info() {
696        // Verify the package version is defined
697        assert!(!env!("CARGO_PKG_VERSION").is_empty());
698    }
699
700    #[test]
701    fn test_resolve_local_spooler_rejects_relative_override() {
702        let error = resolve_local_spooler(Some(PathBuf::from("relative/spool")))
703            .expect_err("relative spool directory must be rejected");
704
705        assert!(matches!(
706            error,
707            SshMcpError::Config(message) if message.contains("must be absolute")
708        ));
709    }
710
711    #[test]
712    fn test_shell_tool_definition() {
713        let tool = SshMcpServer::shell_tool();
714        assert_eq!(tool.name.as_ref(), "shell");
715        assert!(tool.description.is_some());
716    }
717
718    #[test]
719    fn test_sudo_shell_tool_definition() {
720        let tool = SshMcpServer::sudo_shell_tool();
721        assert_eq!(tool.name.as_ref(), "sudo_shell");
722        assert!(tool.description.is_some());
723    }
724
725    #[test]
726    fn test_apply_patch_tool_definition() {
727        let tool = SshMcpServer::apply_patch_tool();
728        assert_eq!(tool.name.as_ref(), "apply_patch");
729        assert!(tool.description.is_some());
730    }
731
732    #[test]
733    fn test_sudo_apply_patch_tool_definition() {
734        let tool = SshMcpServer::sudo_apply_patch_tool();
735        assert_eq!(tool.name.as_ref(), "sudo_apply_patch");
736        assert!(tool.description.is_some());
737    }
738
739    #[test]
740    fn test_build_background_wrapper_escapes_single_quotes_in_user_command() {
741        let remote_log = remote_job_log_path("job-1");
742        let script = build_background_wrapper_script("job-1", "echo 'hello world'", &remote_log);
743        assert!(script.contains("exec sh -c 'set +m; echo '\"'\"'hello world'\"'\"''"));
744    }
745
746    #[test]
747    fn test_build_background_wrapper_is_busybox_friendly() {
748        let remote_log = remote_job_log_path("job-1");
749        let script = build_background_wrapper_script("job-1", "echo test", &remote_log);
750        assert!(!script.contains("dirname --"));
751        assert!(!script.contains("mkdir -p --"));
752        assert!(!script.contains("sh -lc"));
753        assert!(script.contains("exec sh -c"));
754        assert!(!script.contains("nohup"));
755    }
756
757    #[test]
758    fn test_background_wrapper_emits_markers_and_exec() {
759        let remote_log = remote_job_log_path("job-1");
760        let script = build_background_wrapper_script("job-1", "echo test", &remote_log);
761        assert!(script.contains("__SSH_MCP_JOB_ID=job-1"));
762        assert!(script.contains("__SSH_MCP_PID=$$"));
763        assert!(script.contains("__SSH_MCP_LOG=$LOG"));
764        assert!(script.contains("exec sh -c"));
765    }
766
767    #[test]
768    fn test_background_wrapper_does_not_redirect_remote_output() {
769        let remote_log = remote_job_log_path("job-1");
770        let script = build_background_wrapper_script("job-1", "echo test", &remote_log);
771        assert!(!script.contains(">$LOG"));
772        assert!(!script.contains("2>&1"));
773        assert!(!script.contains("$EXIT"));
774        assert!(!script.contains("nohup"));
775    }
776
777    #[test]
778    fn test_validate_background_log_path_rejects_leading_dash() {
779        let err =
780            validate_background_log_path(Path::new("/tmp/ssh-mcp"), "-not-a-path").unwrap_err();
781        assert!(err.contains("start with '-'") || err.contains("start with"));
782    }
783
784    #[test]
785    fn test_validate_background_log_path_rejects_newlines() {
786        assert!(
787            validate_background_log_path(Path::new("/tmp/ssh-mcp"), "/tmp/x\nrm -rf /").is_err()
788        );
789        assert!(
790            validate_background_log_path(Path::new("/tmp/ssh-mcp"), "/tmp/x\rrm -rf /").is_err()
791        );
792    }
793
794    #[test]
795    fn test_background_json_err_omits_unregistered_job_fields() {
796        let long_error = "e".repeat(BACKGROUND_JSON_SNIPPET_LIMIT_CHARS + 10);
797        let long_stderr = "s".repeat(BACKGROUND_JSON_SNIPPET_LIMIT_CHARS + 10);
798
799        let result = background_json_err(&long_error, &long_stderr);
800        let text = extract_text_from_result(&result);
801
802        let value: serde_json::Value =
803            serde_json::from_str(text.trim()).expect("background_json_err should return JSON");
804
805        assert_eq!(value.get("ok").and_then(|v| v.as_bool()), Some(false));
806        assert_eq!(
807            value.get("background").and_then(|v| v.as_bool()),
808            Some(true)
809        );
810        assert_eq!(value.get("truncated").and_then(|v| v.as_bool()), Some(true));
811        assert!(value.get("job_id").is_none());
812        assert!(value.get("log_path").is_none());
813        assert!(value.get("hint").is_none());
814
815        let fields = value
816            .get("truncated_fields")
817            .expect("expected truncated_fields");
818        assert_eq!(fields.get("error").and_then(|v| v.as_bool()), Some(true));
819        assert_eq!(fields.get("stderr").and_then(|v| v.as_bool()), Some(true));
820
821        let error_snippet = value
822            .get("error")
823            .and_then(|v| v.as_str())
824            .expect("expected error field");
825        assert_eq!(
826            error_snippet.chars().count(),
827            BACKGROUND_JSON_SNIPPET_LIMIT_CHARS
828        );
829        let stderr_snippet = value
830            .get("stderr")
831            .and_then(|v| v.as_str())
832            .expect("expected stderr field");
833        assert_eq!(
834            stderr_snippet.chars().count(),
835            BACKGROUND_JSON_SNIPPET_LIMIT_CHARS
836        );
837    }
838
839    #[test]
840    fn test_background_json_timeout_hint_contains_pid_and_check_process_tool() {
841        let result = background_json_timeout(
842            "job-42",
843            4242,
844            "/tmp/ssh-mcp/local.log",
845            &crate::background::response::BackgroundTimeoutSnapshot {
846                state: "running",
847                still_running: true,
848                exit_code: None,
849                state_reason: None,
850                elapsed_time: "00:01",
851                log_exists: true,
852                log_tail: "tail line",
853                tail_lines_used: 50,
854            },
855        );
856        let text = extract_text_from_result(&result);
857
858        let value: serde_json::Value =
859            serde_json::from_str(text.trim()).expect("background_json_timeout should return JSON");
860
861        assert_eq!(value.get("ok").and_then(|v| v.as_bool()), Some(false));
862        assert_eq!(value.get("timeout").and_then(|v| v.as_bool()), Some(true));
863        assert_eq!(
864            value.get("background").and_then(|v| v.as_bool()),
865            Some(true)
866        );
867        assert_eq!(
868            value.get("still_running").and_then(|v| v.as_bool()),
869            Some(true)
870        );
871        assert_eq!(value.get("state").and_then(|v| v.as_str()), Some("running"));
872        assert_eq!(
873            value.get("tail_lines_used").and_then(|v| v.as_u64()),
874            Some(50)
875        );
876        assert_eq!(
877            value.get("elapsed_time").and_then(|v| v.as_str()),
878            Some("00:01")
879        );
880        assert_eq!(
881            value.get("log_tail").and_then(|v| v.as_str()),
882            Some("tail line")
883        );
884
885        let hint = value
886            .get("hint")
887            .and_then(|v| v.as_str())
888            .expect("expected hint field");
889
890        // Hint should contain the actual job_id value
891        assert!(
892            hint.contains("job_id=job-42"),
893            "hint should contain the actual job_id value; got: '{hint}'"
894        );
895        // Hint should mention the check_process tool
896        assert!(
897            hint.contains("check_process"),
898            "hint should mention check_process tool; got: '{hint}'"
899        );
900        // Hint should warn against restarting
901        assert!(
902            hint.contains("DO NOT restart"),
903            "hint should warn against restarting; got: '{hint}'"
904        );
905        // Hint should use TIMEOUT_RECOVERY prefix
906        assert!(
907            hint.contains("TIMEOUT_RECOVERY"),
908            "hint should start with TIMEOUT_RECOVERY; got: '{hint}'"
909        );
910        assert!(
911            hint.contains("MCP client deadlines may be shorter than timeout_ms"),
912            "hint should distinguish the client deadline from timeout_ms; got: '{hint}'"
913        );
914        assert!(
915            hint.contains("background=true"),
916            "hint should recommend explicit background mode; got: '{hint}'"
917        );
918        // Hint should NOT contain old placeholders
919        assert!(
920            !hint.contains("<pid>"),
921            "hint should not contain <pid> placeholder; got: '{hint}'"
922        );
923        assert!(
924            !hint.contains("<log_path>"),
925            "hint should not contain <log_path> placeholder; got: '{hint}'"
926        );
927    }
928}