pty-mcp 0.1.0

An MCP server for PTY management with SSH connections, remote sessions, file access, and mounts
Documentation
use rmcp::model::CallToolResult;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum PtyErrorCode {
    SessionNotFound,
    SessionNotRunning,
    PermissionDenied,
    InvalidArgument,
    InvalidRegex,
    SpawnFailed,
    WriteFailed,
    ReadFailed,
    Timeout,
    NotImplemented,
    SshConnectionNotFound,
    SshConnectionNotReady,
    SshAuthFailed,
    SshHostUnreachable,
    SshHostKeyRejected,
    SshCapabilityUnavailable,
    SshMountNotFound,
    SshMountFailed,
    SshUnmountFailed,
    SshActiveSessionExists,
    SshActiveMountExists,
}

impl PtyErrorCode {
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::SessionNotFound => "SESSION_NOT_FOUND",
            Self::SessionNotRunning => "SESSION_NOT_RUNNING",
            Self::PermissionDenied => "PERMISSION_DENIED",
            Self::InvalidArgument => "INVALID_ARGUMENT",
            Self::InvalidRegex => "INVALID_REGEX",
            Self::SpawnFailed => "SPAWN_FAILED",
            Self::WriteFailed => "WRITE_FAILED",
            Self::ReadFailed => "READ_FAILED",
            Self::Timeout => "TIMEOUT",
            Self::NotImplemented => "NOT_IMPLEMENTED",
            Self::SshConnectionNotFound => "SSH_CONNECTION_NOT_FOUND",
            Self::SshConnectionNotReady => "SSH_CONNECTION_NOT_READY",
            Self::SshAuthFailed => "SSH_AUTH_FAILED",
            Self::SshHostUnreachable => "SSH_HOST_UNREACHABLE",
            Self::SshHostKeyRejected => "SSH_HOST_KEY_REJECTED",
            Self::SshCapabilityUnavailable => "SSH_CAPABILITY_UNAVAILABLE",
            Self::SshMountNotFound => "SSH_MOUNT_NOT_FOUND",
            Self::SshMountFailed => "SSH_MOUNT_FAILED",
            Self::SshUnmountFailed => "SSH_UNMOUNT_FAILED",
            Self::SshActiveSessionExists => "SSH_ACTIVE_SESSION_EXISTS",
            Self::SshActiveMountExists => "SSH_ACTIVE_MOUNT_EXISTS",
        }
    }
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct PtyError {
    pub error_code: PtyErrorCode,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub details: Option<Value>,
}

impl std::fmt::Display for PtyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}: {}", self.error_code.as_str(), self.message)
    }
}

impl std::error::Error for PtyError {}

impl PtyError {
    pub fn new(error_code: PtyErrorCode, message: impl Into<String>) -> Self {
        Self {
            error_code,
            message: message.into(),
            details: None,
        }
    }

    pub fn with_details(mut self, details: impl Into<Value>) -> Self {
        self.details = Some(details.into());
        self
    }

    pub fn not_implemented(tool_name: &str) -> Self {
        Self::new(
            PtyErrorCode::NotImplemented,
            format!("{tool_name} is not implemented yet"),
        )
        .with_details(json!({
            "tool": tool_name,
            "implemented_phases": ["S0", "S1"],
        }))
    }

    pub fn to_call_tool_result(&self) -> CallToolResult {
        let mut body = json!({
            "error_code": self.error_code.as_str(),
            "message": self.message,
        });

        if let Some(details) = &self.details {
            body["details"] = details.clone();
        }

        CallToolResult::structured_error(body)
    }
}