Skip to main content

ssh_mcp/tools/
mod.rs

1//! MCP Tools module
2//!
3//! This module previously provided separate tool classes with #[tool_router].
4//! Now, tools are implemented directly in the SshMcpServer via ServerHandler trait.
5//!
6//! Available tools:
7//! - `shell` - Execute shell commands on the remote SSH server
8//! - `sudo_shell` - Execute shell commands with sudo privileges
9//! - `check_process` - Check if a process is still running and read its log
10//! - `transfer` - Transfer files and directories over SSH
11//! - `apply_patch` - Create, update, or delete one remote UTF-8 text file
12//! - `sudo_apply_patch` - Apply the same exact patch under sudo
13//!
14//! See `server.rs` for the implementation.
15
16// The tools are now implemented directly in server.rs as part of ServerHandler.
17// This module is kept for potential future expansion with additional tools
18// or utility functions.
19
20use schemars::JsonSchema;
21use serde::{Deserialize, Serialize};
22
23pub(crate) const DEFAULT_CHECK_PROCESS_TAIL_LINES: usize = 50;
24
25/// Parameters for the shell tool
26#[derive(Debug, Deserialize, Serialize, JsonSchema)]
27pub struct ExecParams {
28    /// Shell command to execute on the remote SSH server
29    pub command: String,
30
31    /// Background execution mode.
32    ///
33    /// If true, run the command in background and return immediately.
34    /// The server continues streaming output into a local log file on the MCP server and
35    /// tracks the job via an in-memory registry keyed by job_id.
36    /// The tool returns JSON metadata (job_id/pid/log_path/log_exists).
37    #[serde(default)]
38    pub background: bool,
39
40    /// Optional timeout override in milliseconds for foreground execution
41    pub timeout_ms: Option<u64>,
42
43    /// Local log path for background mode output (stored on MCP server)
44    ///
45    /// Defaults to <job_id>.log in the configured or platform-default local spool.
46    pub log_path: Option<String>,
47}
48
49/// Parameters for the sudo_shell tool
50#[derive(Debug, Deserialize, Serialize, JsonSchema)]
51pub struct SudoExecParams {
52    /// Shell command to execute with sudo on the remote SSH server
53    pub command: String,
54
55    /// Background execution mode.
56    ///
57    /// If true, run the command in background and return immediately.
58    /// The server continues streaming output into a local log file on the MCP server and
59    /// tracks the job via an in-memory registry keyed by job_id.
60    /// The tool returns JSON metadata (job_id/pid/log_path/log_exists).
61    #[serde(default)]
62    pub background: bool,
63
64    /// Optional timeout override in milliseconds for foreground execution
65    pub timeout_ms: Option<u64>,
66
67    /// Local log path for background mode output (stored on MCP server)
68    ///
69    /// Defaults to <job_id>.log in the configured or platform-default local spool.
70    pub log_path: Option<String>,
71}
72
73/// Parameters for the check_process tool
74///
75/// # Migration from old API
76/// Previously required `pid` and `log_path`. Now uses `job_id` only.
77/// The job_id is returned by shell/sudo_shell when background=true.
78#[derive(Debug, Deserialize, Serialize, JsonSchema)]
79pub struct CheckProcessParams {
80    /// Job ID returned by shell/sudo_shell background execution
81    pub job_id: String,
82
83    /// Number of last lines to read from log (default: 50)
84    #[serde(default = "default_tail_lines")]
85    pub tail_lines: usize,
86}
87
88/// Parameters for the apply_patch tool
89#[derive(Debug, Deserialize, Serialize, JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct ApplyPatchParams {
92    /// One-file patch envelope with an absolute remote path
93    pub patch: String,
94}
95
96fn default_tail_lines() -> usize {
97    DEFAULT_CHECK_PROCESS_TAIL_LINES
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn test_exec_params_deserialize() {
106        let json = r#"{"command": "echo hello"}"#;
107        let params: ExecParams = serde_json::from_str(json).unwrap();
108        assert_eq!(params.command, "echo hello");
109        assert!(!params.background);
110        assert!(params.timeout_ms.is_none());
111        assert!(params.log_path.is_none());
112    }
113
114    #[test]
115    fn test_exec_params_deserialize_background() {
116        let json = r#"{"command": "sleep 10", "background": true, "timeout_ms": 1000, "log_path": "/tmp/x.log"}"#;
117        let params: ExecParams = serde_json::from_str(json).unwrap();
118        assert_eq!(params.command, "sleep 10");
119        assert!(params.background);
120        assert_eq!(params.timeout_ms, Some(1000));
121        assert_eq!(params.log_path.as_deref(), Some("/tmp/x.log"));
122    }
123
124    #[test]
125    fn test_sudo_exec_params_deserialize() {
126        let json = r#"{"command": "apt update"}"#;
127        let params: SudoExecParams = serde_json::from_str(json).unwrap();
128        assert_eq!(params.command, "apt update");
129        assert!(!params.background);
130        assert!(params.timeout_ms.is_none());
131        assert!(params.log_path.is_none());
132    }
133
134    #[test]
135    fn test_check_process_params_deserialize() {
136        let json = r#"{"job_id": "job-123"}"#;
137        let params: CheckProcessParams = serde_json::from_str(json).unwrap();
138        assert_eq!(params.job_id, "job-123");
139        assert_eq!(params.tail_lines, 50);
140    }
141
142    #[test]
143    fn test_check_process_params_with_tail_lines() {
144        let json = r#"{"job_id": "job-123", "tail_lines": 100}"#;
145        let params: CheckProcessParams = serde_json::from_str(json).unwrap();
146        assert_eq!(params.job_id, "job-123");
147        assert_eq!(params.tail_lines, 100);
148    }
149
150    #[test]
151    fn test_apply_patch_params_deserialize_and_reject_unknown_fields() {
152        let json = r#"{"patch":"*** Begin Patch\n*** Delete File: /tmp/old\n*** End Patch"}"#;
153        let params: ApplyPatchParams = serde_json::from_str(json).unwrap();
154        assert!(params.patch.contains("*** Delete File"));
155
156        let err =
157            serde_json::from_str::<ApplyPatchParams>(r#"{"patch":"x","remote_path":"/tmp/x"}"#)
158                .unwrap_err();
159        assert!(err.to_string().contains("unknown field `remote_path`"));
160    }
161}