pty-mcp 0.3.0

An MCP server for PTY management with SSH connections, remote sessions, file access, and mounts
Documentation
use std::{
    fs,
    path::{Path, PathBuf},
    time::{SystemTime, UNIX_EPOCH},
};

use pty_mcp::{SshConfig, ssh::SshCapabilityProbe};

#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;

#[derive(Debug)]
struct TempDirGuard {
    path: PathBuf,
}

impl TempDirGuard {
    fn new(prefix: &str) -> Self {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system clock before unix epoch")
            .as_nanos();
        let path =
            std::env::temp_dir().join(format!("pty_mcp_{prefix}_{}_{}", std::process::id(), nanos));
        fs::create_dir_all(&path).expect("create temp directory");
        Self { path }
    }
}

impl Drop for TempDirGuard {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.path);
    }
}

#[cfg(unix)]
#[test]
fn probe_with_fake_binaries_captures_paths_and_versions() {
    let temp = TempDirGuard::new("ssh_probe_ok");

    let ssh_path = temp.path.join("ssh");
    let sshfs_path = temp.path.join("sshfs");
    let umount_path = temp.path.join("umount");
    let diskutil_path = temp.path.join("diskutil");

    write_executable_script(
        &ssh_path,
        "#!/bin/sh\necho 'OpenSSH_9.9p2 fake-build' 1>&2\n",
    );
    write_executable_script(
        &sshfs_path,
        "#!/bin/sh\necho 'SSHFS 3.7.3 (macFUSE 4.6.0)'\n",
    );
    write_executable_script(&umount_path, "#!/bin/sh\necho 'umount util-linux 2.39'\n");
    write_executable_script(&diskutil_path, "#!/bin/sh\necho 'diskutil 999.1'\n");

    let config = SshConfig {
        ssh_bin_path: Some(ssh_path.clone()),
        sshfs_bin_path: Some(sshfs_path.clone()),
        umount_bin_path: Some(umount_path.clone()),
        diskutil_bin_path: Some(diskutil_path.clone()),
        ..SshConfig::default()
    };

    let probe = SshCapabilityProbe::new();
    let capabilities = probe.probe_with_config(&config);

    assert_eq!(capabilities.platform, std::env::consts::OS);
    assert!(capabilities.ssh.available);
    assert_eq!(
        capabilities.ssh.path.as_deref(),
        Some(path_string(&ssh_path).as_str())
    );
    assert!(
        capabilities
            .ssh
            .version
            .as_deref()
            .unwrap_or_default()
            .contains("OpenSSH_9.9p2")
    );

    assert!(capabilities.sshfs.available);
    assert_eq!(
        capabilities.sshfs.path.as_deref(),
        Some(path_string(&sshfs_path).as_str())
    );
    assert!(
        capabilities
            .sshfs
            .version
            .as_deref()
            .unwrap_or_default()
            .contains("SSHFS 3.7.3")
    );

    assert!(capabilities.unmount.available);
    assert_eq!(
        capabilities.unmount.path.as_deref(),
        Some(path_string(&umount_path).as_str())
    );

    if cfg!(target_os = "macos") {
        let diskutil = capabilities
            .diskutil
            .as_ref()
            .expect("macOS capability should include diskutil");
        assert!(diskutil.available);
        assert_eq!(
            diskutil.path.as_deref(),
            Some(path_string(&diskutil_path).as_str())
        );

        let macfuse = capabilities
            .macfuse
            .as_ref()
            .expect("macOS capability should include macfuse field");
        assert!(macfuse.available);
        assert_eq!(macfuse.provider.as_deref(), Some("macFUSE"));
    } else {
        assert!(capabilities.diskutil.is_none());
        assert!(capabilities.macfuse.is_none());
    }
}

#[test]
fn probe_marks_missing_configured_paths_as_unavailable() {
    let temp = TempDirGuard::new("ssh_probe_missing");
    let missing_ssh = temp.path.join("missing-ssh");
    let missing_sshfs = temp.path.join("missing-sshfs");
    let missing_umount = temp.path.join("missing-umount");

    let config = SshConfig {
        ssh_bin_path: Some(missing_ssh.clone()),
        sshfs_bin_path: Some(missing_sshfs.clone()),
        umount_bin_path: Some(missing_umount.clone()),
        ..SshConfig::default()
    };

    let probe = SshCapabilityProbe::new();
    let capabilities = probe.probe_with_config(&config);

    assert!(!capabilities.ssh.available);
    assert_eq!(
        capabilities.ssh.path.as_deref(),
        Some(path_string(&missing_ssh).as_str())
    );
    assert!(capabilities.ssh.version.is_none());

    assert!(!capabilities.sshfs.available);
    assert_eq!(
        capabilities.sshfs.path.as_deref(),
        Some(path_string(&missing_sshfs).as_str())
    );
    assert!(capabilities.sshfs.version.is_none());

    assert!(!capabilities.unmount.available);
    assert_eq!(
        capabilities.unmount.path.as_deref(),
        Some(path_string(&missing_umount).as_str())
    );
    assert!(capabilities.unmount.version.is_none());
}

#[cfg(unix)]
#[test]
fn probe_keeps_available_when_version_flags_are_unsupported() {
    let temp = TempDirGuard::new("ssh_probe_no_version");
    let sshfs_path = temp.path.join("sshfs");
    let umount_path = temp.path.join("umount");

    write_executable_script(
        &sshfs_path,
        "#!/bin/sh\necho 'sshfs: unknown option' 1>&2\nexit 1\n",
    );
    write_executable_script(
        &umount_path,
        "#!/bin/sh\necho 'usage: umount target' 1>&2\nexit 1\n",
    );

    let config = SshConfig {
        sshfs_bin_path: Some(sshfs_path.clone()),
        umount_bin_path: Some(umount_path.clone()),
        ..SshConfig::default()
    };

    let probe = SshCapabilityProbe::new();
    let capabilities = probe.probe_with_config(&config);

    assert!(capabilities.sshfs.available);
    assert_eq!(
        capabilities.sshfs.path.as_deref(),
        Some(path_string(&sshfs_path).as_str())
    );
    assert!(capabilities.sshfs.version.is_none());

    assert!(capabilities.unmount.available);
    assert_eq!(
        capabilities.unmount.path.as_deref(),
        Some(path_string(&umount_path).as_str())
    );
    assert!(capabilities.unmount.version.is_none());
}

#[cfg(unix)]
fn write_executable_script(path: &Path, content: &str) {
    fs::write(path, content).expect("write fake executable");
    let mut permissions = fs::metadata(path)
        .expect("stat fake executable")
        .permissions();
    permissions.set_mode(0o755);
    fs::set_permissions(path, permissions).expect("chmod fake executable");
}

fn path_string(path: &Path) -> String {
    path.display().to_string()
}