1use 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#[derive(Clone)]
95pub struct SshMcpServer {
96 config: Config,
98
99 connection: Arc<SshConnectionManager>,
101
102 timeout: Duration,
104
105 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 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 let mut ssh_config = SshConfig::new(&config.host, &config.user).with_port(config.port);
136
137 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 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 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 ssh_config = ssh_config
161 .with_keepalive_interval(config.keepalive_interval)
162 .with_keepalive_max(config.keepalive_max);
163
164 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 ssh_config = ssh_config
172 .with_host_key_checking(config.strict_host_key_checking)
173 .with_known_hosts(config.known_hosts.clone());
174
175 ssh_config = ssh_config.with_max_output_tokens(config.max_output_tokens);
177
178 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 pub fn connection(&self) -> &Arc<SshConnectionManager> {
306 &self.connection
307 }
308
309 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 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 let sanitized = match self.sanitize_or_tool_error(command) {
356 Ok(cmd) => cmd,
357 Err(result) => return Ok(result),
358 };
359
360 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 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 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 let sanitized = match self.sanitize_or_tool_error(command) {
439 Ok(cmd) => cmd,
440 Err(result) => return Ok(result),
441 };
442
443 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 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 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 fn shell_tool() -> Tool {
542 tools::shell_tool()
543 }
544
545 fn sudo_shell_tool() -> Tool {
547 tools::sudo_shell_tool()
548 }
549
550 fn transfer_tool() -> Tool {
552 tools::transfer_tool()
553 }
554
555 fn check_process_tool() -> Tool {
557 tools::check_process_tool()
558 }
559
560 fn read_file_tool() -> Tool {
562 tools::read_file_tool()
563 }
564
565 fn apply_patch_tool() -> Tool {
567 tools::apply_patch_tool()
568 }
569
570 pub fn get_tool_documentation(tool_name: &str) -> Option<&'static str> {
575 tools::get_tool_documentation(tool_name)
576 }
577
578 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 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 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 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 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 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 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 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 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 #[test]
782 fn test_server_info() {
783 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 assert!(
1075 hint.contains("job_id=job-42"),
1076 "hint should contain the actual job_id value; got: '{hint}'"
1077 );
1078 assert!(
1080 hint.contains("check_process"),
1081 "hint should mention check_process tool; got: '{hint}'"
1082 );
1083 assert!(
1085 hint.contains("DO NOT restart"),
1086 "hint should warn against restarting; got: '{hint}'"
1087 );
1088 assert!(
1090 hint.contains("TIMEOUT_RECOVERY"),
1091 "hint should start with TIMEOUT_RECOVERY; got: '{hint}'"
1092 );
1093 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 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 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 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}