mod docker_integration;
pub use docker_integration::common::*;
#[tokio::test]
async fn test_mcp_tools_with_docker() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
let container = GenericImage::new("ssh-mcp-debian-sshd", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
tracing::info!("SSH container started at {}:{}", host, port);
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: Some("secret".to_string()),
key: None,
su_password: None,
sudo_password: Some("secret".to_string()),
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: false,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config.clone())
.await
.expect("Failed to create SshMcpServer");
tracing::info!("SshMcpServer created successfully");
let exec_result = server
.test_execute_command("whoami")
.await
.expect("exec command failed");
let exec_output = extract_text_from_result(&exec_result);
let exec_output = exec_output.trim();
assert!(
exec_output.contains("test"),
"exec 'whoami' should return 'test', got: '{}'",
exec_output
);
tracing::info!("shell behavior verified: whoami returned 'test'");
let patch_dir = "/tmp/ssh-mcp-apply-patch";
let add_path = "/tmp/ssh-mcp-apply-patch/added.txt";
let edit_path = "/tmp/ssh-mcp-apply-patch/edit.txt";
let delete_path = "/tmp/ssh-mcp-apply-patch/delete.txt";
server
.test_execute_command(&format!(
r#"sh -c 'set -eu; rm -rf -- {dir}; mkdir -p -- {dir}; printf "alpha\nbeta\nomega\n" > {edit}; printf "remove me\n" > {delete}'"#,
dir = ssh_mcp::escape_for_shell(patch_dir),
edit = ssh_mcp::escape_for_shell(edit_path),
delete = ssh_mcp::escape_for_shell(delete_path),
))
.await
.expect("failed to prepare apply_patch fixtures");
let add_patch = format!("*** Begin Patch\n*** Add File: {add_path}\n+created\n*** End Patch");
let add_result = server
.test_apply_patch(&add_patch)
.await
.expect("apply_patch Add call failed");
assert!(
!add_result.is_error.unwrap_or(false),
"apply_patch Add failed: {}",
extract_text_from_result(&add_result)
);
let add_json: serde_json::Value =
serde_json::from_str(extract_text_from_result(&add_result).trim())
.expect("Add response should be valid JSON");
assert_eq!(
add_json,
serde_json::json!({"ok": true, "path": add_path, "operation": "add"})
);
let update_patch = format!(
"*** Begin Patch\n*** Update File: {edit_path}\n@@\n alpha\n-beta\n+gamma\n omega\n*** End Patch"
);
let update_result = server
.test_apply_patch(&update_patch)
.await
.expect("apply_patch Update call failed");
assert!(!update_result.is_error.unwrap_or(false));
let updated = server
.test_execute_command(&format!("cat -- {}", ssh_mcp::escape_for_shell(edit_path)))
.await
.expect("failed to inspect updated file");
assert_eq!(extract_text_from_result(&updated), "alpha\ngamma\nomega\n");
let conflict_patch =
format!("*** Begin Patch\n*** Update File: {edit_path}\n@@\n-gamma\n+delta\n*** End Patch");
let race_result = server
.test_apply_patch_mutate_before_commit(&conflict_patch)
.await
.expect("apply_patch race call failed");
assert!(race_result.is_error.unwrap_or(false));
let race_json: serde_json::Value =
serde_json::from_str(extract_text_from_result(&race_result).trim())
.expect("race response should be valid JSON");
assert_eq!(
race_json.get("error").and_then(|v| v.as_str()),
Some("conflict")
);
let delete_patch = format!("*** Begin Patch\n*** Delete File: {delete_path}\n*** End Patch");
let delete_result = server
.test_apply_patch(&delete_patch)
.await
.expect("apply_patch Delete call failed");
assert!(!delete_result.is_error.unwrap_or(false));
let delete_probe = server
.test_execute_command(&format!(
"test ! -e {} && printf deleted",
ssh_mcp::escape_for_shell(delete_path)
))
.await
.expect("failed to verify deleted file");
assert_eq!(extract_text_from_result(&delete_probe), "deleted");
let privileged_dir = "/tmp/ssh-mcp-sudo-apply-patch";
let privileged_update = "/tmp/ssh-mcp-sudo-apply-patch/update.txt";
let privileged_delete = "/tmp/ssh-mcp-sudo-apply-patch/delete.txt";
let privileged_add = "/tmp/ssh-mcp-sudo-apply-patch/add.txt";
server
.test_execute_sudo_command(&format!(
r#"rm -rf -- {dir}; mkdir -- {dir}; printf 'before\n' > {update}; printf 'delete me\n' > {delete}; chmod 0755 -- {dir}; chown -R root:root -- {dir}"#,
dir = ssh_mcp::escape_for_shell(privileged_dir),
update = ssh_mcp::escape_for_shell(privileged_update),
delete = ssh_mcp::escape_for_shell(privileged_delete),
))
.await
.expect("failed to prepare privileged apply_patch fixtures");
let privileged_update_patch = format!(
"*** Begin Patch\n*** Update File: {privileged_update}\n@@\n-before\n+after\n*** End Patch"
);
let privileged_add_patch =
format!("*** Begin Patch\n*** Add File: {privileged_add}\n+added\n*** End Patch");
let privileged_delete_patch =
format!("*** Begin Patch\n*** Delete File: {privileged_delete}\n*** End Patch");
for patch in [
&privileged_update_patch,
&privileged_add_patch,
&privileged_delete_patch,
] {
let result = server
.test_apply_patch(patch)
.await
.expect("unprivileged apply_patch call failed");
let body: serde_json::Value =
serde_json::from_str(extract_text_from_result(&result).trim())
.expect("permission response should be valid JSON");
assert!(result.is_error.unwrap_or(false));
assert_eq!(
body.get("error").and_then(|value| value.as_str()),
Some("permission_denied")
);
assert!(
body.get("message")
.and_then(|value| value.as_str())
.is_some_and(|message| message.contains("does not elevate privileges"))
);
}
let unchanged = server
.test_execute_command(&format!(
"cat -- {} {}; test ! -e {}",
ssh_mcp::escape_for_shell(privileged_update),
ssh_mcp::escape_for_shell(privileged_delete),
ssh_mcp::escape_for_shell(privileged_add),
))
.await
.expect("failed to verify protected fixtures");
assert_eq!(extract_text_from_result(&unchanged), "before\ndelete me\n");
let mut passwordless_config = config.clone();
passwordless_config.sudo_password = None;
let passwordless_server = SshMcpServer::new(passwordless_config)
.await
.expect("failed to create passwordless sudo server");
let passwordless_result = passwordless_server
.test_sudo_apply_patch(&privileged_update_patch)
.await
.expect("passwordless sudo_apply_patch failed");
assert!(!passwordless_result.is_error.unwrap_or(false));
let privileged_conflict_patch = format!(
"*** Begin Patch\n*** Update File: {privileged_update}\n@@\n-after\n+should-not-commit\n*** End Patch"
);
let privileged_conflict = passwordless_server
.test_sudo_apply_patch_mutate_before_commit(&privileged_conflict_patch)
.await
.expect("sudo_apply_patch conflict call failed");
let privileged_conflict_body: serde_json::Value =
serde_json::from_str(extract_text_from_result(&privileged_conflict).trim())
.expect("sudo conflict response should be valid JSON");
assert_eq!(
privileged_conflict_body
.get("error")
.and_then(|value| value.as_str()),
Some("conflict")
);
passwordless_server.shutdown().await;
server
.test_execute_sudo_command(&format!(
r#"printf 'before\n' > {update}; printf 'test ALL=(ALL) ALL\n' > /etc/sudoers.d/test; chmod 0440 /etc/sudoers.d/test"#,
update = ssh_mcp::escape_for_shell(privileged_update),
))
.await
.expect("failed to enable password-required sudo");
for patch in [
&privileged_update_patch,
&privileged_add_patch,
&privileged_delete_patch,
] {
let result = server
.test_sudo_apply_patch(patch)
.await
.expect("password-based sudo_apply_patch failed");
assert!(
!result.is_error.unwrap_or(false),
"sudo_apply_patch returned an error: {}",
extract_text_from_result(&result)
);
}
let privileged_state = server
.test_execute_command(&format!(
"cat -- {} {}; test ! -e {}",
ssh_mcp::escape_for_shell(privileged_update),
ssh_mcp::escape_for_shell(privileged_add),
ssh_mcp::escape_for_shell(privileged_delete),
))
.await
.expect("failed to inspect privileged edit results");
assert_eq!(
extract_text_from_result(&privileged_state),
"after\nadded\n"
);
server
.test_execute_sudo_command(&format!(
"rm -rf -- {}",
ssh_mcp::escape_for_shell(privileged_dir)
))
.await
.expect("failed to clean up privileged apply_patch fixtures");
let timeout_result = server
.test_execute_command_with_timeout_ms("sleep 2; echo done", 1100)
.await
.expect("exec command with timeout override failed");
let timeout_text = extract_text_from_result(&timeout_result);
let timeout_json: serde_json::Value = serde_json::from_str(timeout_text.trim())
.expect("timeout->detach response should be valid JSON");
assert_eq!(
timeout_json.get("ok").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
timeout_json.get("timeout").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
timeout_json.get("background").and_then(|v| v.as_bool()),
Some(true)
);
assert!(
timeout_json.get("hint").and_then(|v| v.as_str()).is_some(),
"timeout->detach response should include a pragmatic hint"
);
let log_path = timeout_json
.get("log_path")
.and_then(|v| v.as_str())
.expect("timeout->detach response should include log_path");
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(10);
let poll_interval = tokio::time::Duration::from_millis(250);
let mut last_log_text = String::new();
loop {
if tokio::time::Instant::now() >= deadline {
panic!(
"detached job log did not contain 'done' within deadline; last log: '{}'",
last_log_text
);
}
let log_text = match tokio::fs::read_to_string(log_path).await {
Ok(s) => s,
Err(e) => {
last_log_text = format!("<read error: {e}>");
tokio::time::sleep(poll_interval).await;
continue;
}
};
last_log_text = log_text.clone();
if log_text.contains("done") {
break;
}
tokio::time::sleep(poll_interval).await;
}
let sudo_result = server
.test_execute_sudo_command("whoami")
.await
.expect("sudo command failed");
let sudo_output = extract_text_from_result(&sudo_result);
let sudo_output = sudo_output.trim();
if sudo_output.contains("root") {
tracing::info!("sudo_shell behavior verified: whoami returned 'root'");
} else {
tracing::warn!(
"sudo_shell 'whoami' did not return 'root', got: '{}'",
sudo_output
);
tracing::warn!("This may be due to container sudo configuration limitations");
}
server.shutdown().await;
tracing::info!("Server shut down successfully");
}
#[cfg(unix)]
mod unix_transfer_tests {
use super::*;
#[tokio::test]
async fn test_transfer_auto_uses_sftp_when_available() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
if !check_openssh_client("sftp") {
tracing::warn!("skipping: local 'sftp' client unavailable");
return;
}
if !check_openssh_client("scp") {
tracing::warn!("skipping: local 'scp' client unavailable");
return;
}
let key_dir = tempfile::TempDir::new().expect("tempdir");
let key_path = key_dir.path().join("id_ed25519");
std::fs::write(&key_path, TEST_PRIVATE_KEY).expect("write private key");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
std::fs::set_permissions(&key_path, perms).expect("chmod key");
}
let container = GenericImage::new("ssh-mcp-debian-sshd", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: None,
key: Some(key_path.clone()),
su_password: None,
sudo_password: None,
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: true,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let home_result = server
.test_execute_command(r#"sh -c 'printf %s "$HOME"'"#)
.await
.expect("failed to resolve remote HOME");
let remote_home = extract_text_from_result(&home_result).trim().to_string();
assert!(!remote_home.is_empty(), "remote HOME should not be empty");
let unique = format!(
"{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
);
let local_base =
std::path::PathBuf::from("target/tmp").join(format!("transfer-it-{unique}"));
std::fs::create_dir_all(&local_base).expect("create local base");
let local_file = local_base.join("hello.txt");
std::fs::write(&local_file, "hello via transfer\n").expect("write local file");
let local_path_param = local_file.to_string_lossy().to_string();
let remote_file = format!("{remote_home}/hello.txt");
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param,
remote_path: remote_file.clone(),
transport: TransferTransport::Auto,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(resp.ok, "transfer should succeed: {:?}", resp.error);
assert_eq!(
resp.transport_used,
TransferTransport::Sftp,
"auto should prefer sftp when available"
);
let verify = server
.test_execute_command(&format!(
"sh -c 'cat < {}'",
ssh_mcp::escape_for_shell(&remote_file)
))
.await
.expect("verify remote file");
let verify_text = extract_text_from_result(&verify);
assert!(verify_text.contains("hello via transfer"));
let local_dir = local_base.join("dir");
std::fs::create_dir_all(local_dir.join("nested")).expect("create local dir tree");
std::fs::write(local_dir.join("nested").join("a.txt"), "a\n").expect("write nested file");
let local_dir_param = local_dir.to_string_lossy().to_string();
let remote_dir = format!("{remote_home}/recv-dir");
let dir_resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_dir_param,
remote_path: remote_dir.clone(),
transport: TransferTransport::Auto,
kind: Some(TransferKind::Directory),
overwrite: true,
timeout_ms: Some(30000),
verbose: false,
..Default::default()
})
.await;
assert!(
dir_resp.ok,
"directory transfer should succeed: {:?}",
dir_resp.error
);
let dir_verify = server
.test_execute_command(&format!(
"sh -c 'test -f {}/nested/a.txt && printf ok'",
ssh_mcp::escape_for_shell(&remote_dir)
))
.await
.expect("verify remote dir");
assert!(extract_text_from_result(&dir_verify).contains("ok"));
server.shutdown().await;
let _ = std::fs::remove_dir_all(&local_base);
}
}
#[tokio::test]
async fn test_compact_response_has_paths() {
init_test_env().expect("Failed to initialize test environment");
let _ = tracing_subscriber::fmt()
.with_test_writer()
.with_env_filter("ssh_mcp=debug,info")
.try_init();
let container = GenericImage::new("ssh-mcp-debian-sshd", "latest")
.with_exposed_port(2222u16.into())
.start()
.await
.expect("Failed to start SSH container");
let host = container
.get_host()
.await
.expect("Failed to get container host");
let port = container
.get_host_port_ipv4(2222)
.await
.expect("Failed to get mapped SSH port");
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let config = Config {
host: host.to_string(),
port,
user: "test".to_string(),
password: Some("secret".to_string()),
key: None,
su_password: None,
sudo_password: Some("secret".to_string()),
timeout_ms: 30000,
max_chars: Some(1000),
max_output_tokens: Some(12000),
disable_sudo: false,
keepalive_interval: 30,
keepalive_max: 3,
reconnect_retries: 3,
reconnect_backoff_ms: 250,
health_probe_timeout_ms: 1500,
strict_host_key_checking: ssh_mcp::HostKeyCheckMode::No,
known_hosts: None,
};
let server = SshMcpServer::new(config)
.await
.expect("Failed to create SshMcpServer");
let home_result = server
.test_execute_command(r#"sh -c 'printf %s "$HOME"'"#)
.await
.expect("failed to resolve remote HOME");
let remote_home = extract_text_from_result(&home_result).trim().to_string();
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let temp = std::path::PathBuf::from("target/tmp").join(format!("compact-test-{unique}"));
std::fs::create_dir_all(&temp).expect("create temp dir");
let test_file = temp.join("test.txt");
tokio::fs::write(&test_file, "test content for compact response")
.await
.expect("write test file");
let local_path_param = test_file.to_string_lossy().to_string();
let remote_file = format!("{}/test_compact.txt", remote_home);
let resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param.clone(),
remote_path: remote_file.clone(),
transport: TransferTransport::ExecRaw,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: false, ..Default::default()
})
.await;
assert!(resp.ok, "transfer should succeed: {:?}", resp.error);
let compact_json = resp
.to_json(false)
.expect("compact JSON serialization failed");
let json: serde_json::Value =
serde_json::from_str(&compact_json).expect("compact JSON parse failed");
assert_eq!(json["ok"], true, "compact response should have ok=true");
assert_eq!(
json["local_path"].as_str().unwrap_or_default(),
local_path_param,
"compact response should have local_path"
);
assert_eq!(
json["remote_path"].as_str().unwrap_or_default(),
remote_file,
"compact response should have remote_path"
);
let bytes = json["counts"]["bytes"].as_u64().unwrap_or(0);
assert!(
bytes > 0,
"compact response should have counts.bytes > 0, got {bytes}"
);
assert!(
!json["transport_used"].is_null(),
"compact response should have transport_used"
);
assert!(
json["staging"].is_null(),
"compact response should NOT have staging"
);
assert!(
json["resolved"].is_null(),
"compact response should NOT have resolved"
);
let verbose_resp = server
.test_transfer(TransferParams {
operation: TransferOperation::Put,
local_path: local_path_param.clone(),
remote_path: remote_file.clone(),
transport: TransferTransport::ExecRaw,
kind: Some(TransferKind::File),
overwrite: true,
timeout_ms: Some(30000),
verbose: true, ..Default::default()
})
.await;
assert!(
verbose_resp.ok,
"verbose transfer should succeed: {:?}",
verbose_resp.error
);
let verbose_json = verbose_resp
.to_json(true)
.expect("verbose JSON serialization failed");
let json: serde_json::Value =
serde_json::from_str(&verbose_json).expect("verbose JSON parse failed");
assert_eq!(json["ok"], true, "verbose response should have ok=true");
assert_eq!(
json["params"]["local_path"].as_str().unwrap_or_default(),
local_path_param,
"verbose response should have local_path in params"
);
assert_eq!(
json["params"]["remote_path"].as_str().unwrap_or_default(),
remote_file,
"verbose response should have remote_path in params"
);
assert!(
!json["transport_used"].is_null(),
"verbose response should have transport_used"
);
assert!(
!json["params"].is_null(),
"verbose response should have params"
);
server.shutdown().await;
let _ = std::fs::remove_dir_all(&temp);
}