cnctd-service-ssh 0.1.7

SSH command execution service - library and MCP server
Documentation
//! Tool router for the SSH service.
//!
//! Uses the `rmcp` macros exactly like the template. Tools are thin and
//! delegate to `operations`.

use rmcp::{
    ServerHandler,
    handler::server::{router::tool::ToolRouter, wrapper::Parameters},
    model::{CallToolResult, Content, ErrorData as McpError, ServerCapabilities, ServerInfo},
    schemars, tool, tool_handler, tool_router,
};
use serde::Deserialize;

use crate::operations::{self, SshExecArgs, SshRegisterArgs};

/// Args for registering an SSH target.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SshRegisterRequest {
    /// Client-defined identifier for this target (unique key).
    pub id: String,
    /// Hostname or IP of the SSH server.
    pub host: String,
    /// Username to authenticate as.
    pub user: String,
    /// SSH port (default 22).
    #[serde(default = "default_port")]
    pub port: u16,
    /// Optional passphrase for the private key.
    pub key_passphrase: Option<String>,
    /// Path to OpenSSH known_hosts file (default "~/.ssh/known_hosts").
    #[serde(default = "default_known_hosts")]
    pub known_hosts_path: String,
    /// Identifier for crash-recovery log filtering. Use a stable identifier that
    /// survives session restarts. Examples: 'dot:{dot_id}:user:{user_id}' for cnctd.world, 'claude' for Claude Desktop,
    /// '{app_name}:{unique_id}' for other clients. Logs can be filtered
    /// with: grep '"client":"{your_id}"' ~/.cnctd/ssh_exec.jsonl
    #[serde(default)]
    pub client_id: Option<String>,
}

/// Args for executing a command on a registered target.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SshExecRequest {
    /// Target id previously registered via `ssh_register_target`.
    pub id: String,
    /// Shell command to execute remotely.
    pub command: String,
    /// Timeout in seconds (default 120).
    #[serde(default = "default_timeout_secs")]
    pub timeout_secs: u64,
    /// Optional context describing why this command is being run (for crash recovery logs).
    #[serde(default)]
    pub context: Option<String>,
}

/// Args for unregistering a target.
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SshUnregisterRequest {
    /// Target id to remove.
    pub id: String,
}

/// SSH service with a ToolRouter, matching the template's structure.
#[derive(Clone)]
pub struct SshToolRouter {
    pub tool_router: ToolRouter<Self>,
}

#[tool_router]
impl SshToolRouter {
    /// Construct the service and initialize the tool router.
    pub fn new() -> Self {
        Self {
            tool_router: Self::tool_router(),
        }
    }

    /// Register or replace an SSH target configuration.
    #[tool(description = "Register or replace an SSH target configuration")]
    async fn ssh_register_target(
        &self,
        Parameters(req): Parameters<SshRegisterRequest>,
    ) -> Result<CallToolResult, McpError> {
        let args = SshRegisterArgs {
            id: req.id,
            host: req.host,
            user: req.user,
            port: req.port,
            key_passphrase: req.key_passphrase,
            known_hosts_path: req.known_hosts_path,
            client_id: req.client_id,
        };
        let v = operations::ssh_register(args).await?;
        Ok(CallToolResult::success(vec![Content::json(v)?]))
    }

    /// Execute a command on a registered target; returns stdout, stderr, exit code.
    #[tool(description = "Execute a command on a registered target")]
    async fn ssh_exec(
        &self,
        Parameters(req): Parameters<SshExecRequest>,
    ) -> Result<CallToolResult, McpError> {
        let args = SshExecArgs {
            id: req.id,
            command: req.command,
            timeout_secs: req.timeout_secs,
            context: req.context,
        };
        let v = operations::ssh_exec(args).await?;
        Ok(CallToolResult::success(vec![Content::json(v)?]))
    }

    /// Unregister a target by id.
    #[tool(description = "Unregister a previously registered target")]
    async fn ssh_unregister_target(
        &self,
        Parameters(req): Parameters<SshUnregisterRequest>,
    ) -> Result<CallToolResult, McpError> {
        let v = operations::ssh_unregister(req.id).await?;
        Ok(CallToolResult::success(vec![Content::json(v)?]))
    }
}

impl Default for SshToolRouter {
    fn default() -> Self {
        Self::new()
    }
}

#[tool_handler]
impl ServerHandler for SshToolRouter {
    fn get_info(&self) -> ServerInfo {
        ServerInfo {
            instructions: Some("cnctd-service-ssh: secure SSH command execution with enforced host key verification and whitelisted keys".into()),
            capabilities: ServerCapabilities::builder().enable_tools().build(),
            ..Default::default()
        }
    }
}

/* ---------- defaults mirror operations.rs for schema consistency ---------- */

fn default_port() -> u16 {
    22
}
fn default_known_hosts() -> String {
    "~/.ssh/known_hosts".into()
}
fn default_timeout_secs() -> u64 {
    120
}