1use 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#[derive(Clone)]
63pub struct SshMcpServer {
64 config: Config,
66
67 connection: Arc<SshConnectionManager>,
69
70 timeout: Duration,
72
73 max_chars: Option<usize>,
75
76 spooler: Arc<LocalLogSpooler>,
77 job_registry: Arc<JobRegistry>,
78
79 transfer: TransferEngine,
80}
81
82impl SshMcpServer {
83 pub async fn new(config: Config) -> Result<Self> {
88 Self::new_with_spool_dir(config, None).await
89 }
90
91 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 let mut ssh_config = SshConfig::new(&config.host, &config.user).with_port(config.port);
106
107 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 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 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 ssh_config = ssh_config
131 .with_keepalive_interval(config.keepalive_interval)
132 .with_keepalive_max(config.keepalive_max);
133
134 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 ssh_config = ssh_config
142 .with_host_key_checking(config.strict_host_key_checking)
143 .with_known_hosts(config.known_hosts.clone());
144
145 ssh_config = ssh_config.with_max_output_tokens(config.max_output_tokens);
147
148 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 pub fn connection(&self) -> &Arc<SshConnectionManager> {
274 &self.connection
275 }
276
277 pub async fn shutdown(&self) {
279 info!("Shutting down SSH MCP Server...");
280 self.connection.close().await;
281 }
282
283 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 let sanitized = match self.sanitize_or_tool_error(command) {
297 Ok(cmd) => cmd,
298 Err(result) => return Ok(result),
299 };
300
301 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 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 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 let sanitized = match self.sanitize_or_tool_error(command) {
363 Ok(cmd) => cmd,
364 Err(result) => return Ok(result),
365 };
366
367 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 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 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 fn shell_tool() -> Tool {
442 tools::shell_tool()
443 }
444
445 fn sudo_shell_tool() -> Tool {
447 tools::sudo_shell_tool()
448 }
449
450 fn transfer_tool() -> Tool {
452 tools::transfer_tool()
453 }
454
455 fn check_process_tool() -> Tool {
457 tools::check_process_tool()
458 }
459
460 fn apply_patch_tool() -> Tool {
462 tools::apply_patch_tool()
463 }
464
465 fn sudo_apply_patch_tool() -> Tool {
467 tools::sudo_apply_patch_tool()
468 }
469
470 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 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 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 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 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 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 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 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 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 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 assert!(
892 hint.contains("job_id=job-42"),
893 "hint should contain the actual job_id value; got: '{hint}'"
894 );
895 assert!(
897 hint.contains("check_process"),
898 "hint should mention check_process tool; got: '{hint}'"
899 );
900 assert!(
902 hint.contains("DO NOT restart"),
903 "hint should warn against restarting; got: '{hint}'"
904 );
905 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 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}