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//! - `read` - Read UTF-8 text files from the remote SSH server
11//! - `apply_patch` - Create, update, or delete one remote UTF-8 text file
12//!
13//! See `server.rs` for the implementation.
14
15// The tools are now implemented directly in server.rs as part of ServerHandler.
16// This module is kept for potential future expansion with additional tools
17// or utility functions.
18
19use schemars::JsonSchema;
20use serde::{Deserialize, Serialize};
21
22pub(crate) const DEFAULT_CHECK_PROCESS_TAIL_LINES: usize = 50;
23
24fn default_read_file_mode() -> ReadFileMode {
25    ReadFileMode::Preview
26}
27
28/// Parameters for the shell tool
29#[derive(Debug, Deserialize, Serialize, JsonSchema)]
30pub struct ExecParams {
31    /// Shell command to execute on the remote SSH server
32    pub command: String,
33
34    /// Background execution mode.
35    ///
36    /// If true, run the command in background and return immediately.
37    /// The server continues streaming output into a local log file on the MCP server and
38    /// tracks the job via an in-memory registry keyed by job_id.
39    /// The tool returns JSON metadata (job_id/pid/log_path/log_exists).
40    #[serde(default)]
41    pub background: bool,
42
43    /// Optional timeout override in milliseconds for foreground execution
44    pub timeout_ms: Option<u64>,
45
46    /// Local log path for background mode output (stored on MCP server)
47    ///
48    /// Defaults to ssh-mcp/<job_id>.log in the system temp directory.
49    pub log_path: Option<String>,
50}
51
52/// Parameters for the sudo_shell tool
53#[derive(Debug, Deserialize, Serialize, JsonSchema)]
54pub struct SudoExecParams {
55    /// Shell command to execute with sudo on the remote SSH server
56    pub command: String,
57
58    /// Background execution mode.
59    ///
60    /// If true, run the command in background and return immediately.
61    /// The server continues streaming output into a local log file on the MCP server and
62    /// tracks the job via an in-memory registry keyed by job_id.
63    /// The tool returns JSON metadata (job_id/pid/log_path/log_exists).
64    #[serde(default)]
65    pub background: bool,
66
67    /// Optional timeout override in milliseconds for foreground execution
68    pub timeout_ms: Option<u64>,
69
70    /// Local log path for background mode output (stored on MCP server)
71    ///
72    /// Defaults to ssh-mcp/<job_id>.log in the system temp directory.
73    pub log_path: Option<String>,
74}
75
76/// Parameters for the check_process tool
77///
78/// # Migration from old API
79/// Previously required `pid` and `log_path`. Now uses `job_id` only.
80/// The job_id is returned by shell/sudo_shell when background=true.
81#[derive(Debug, Deserialize, Serialize, JsonSchema)]
82pub struct CheckProcessParams {
83    /// Job ID returned by shell/sudo_shell background execution
84    pub job_id: String,
85
86    /// Number of last lines to read from log (default: 50)
87    #[serde(default = "default_tail_lines")]
88    pub tail_lines: usize,
89}
90
91/// Parameters for the read tool
92#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
93#[serde(rename_all = "lowercase")]
94pub enum ReadFileMode {
95    /// Safe first-read mode that returns the first chunk of lines
96    Preview,
97    /// Return the first N lines
98    Head,
99    /// Return the last N lines
100    Tail,
101    /// Return the full file (subject to existing size safeguards)
102    Full,
103}
104
105impl ReadFileMode {
106    pub const fn as_str(self) -> &'static str {
107        match self {
108            Self::Preview => "preview",
109            Self::Head => "head",
110            Self::Tail => "tail",
111            Self::Full => "full",
112        }
113    }
114}
115
116#[derive(Debug, Deserialize, Serialize, JsonSchema)]
117pub struct ReadFileParams {
118    /// Absolute remote file path to read
119    pub remote_path: String,
120
121    /// Read mode (default: preview)
122    #[serde(default = "default_read_file_mode")]
123    pub mode: ReadFileMode,
124
125    /// Number of lines for preview/head/tail (default: 800)
126    pub lines: Option<usize>,
127
128    /// Optional timeout override in milliseconds
129    pub timeout_ms: Option<u64>,
130}
131
132/// Parameters for the apply_patch tool
133#[derive(Debug, Deserialize, Serialize, JsonSchema)]
134#[serde(deny_unknown_fields)]
135pub struct ApplyPatchParams {
136    /// One-file patch envelope with an absolute remote path
137    pub patch: String,
138}
139
140fn default_tail_lines() -> usize {
141    DEFAULT_CHECK_PROCESS_TAIL_LINES
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn test_exec_params_deserialize() {
150        let json = r#"{"command": "echo hello"}"#;
151        let params: ExecParams = serde_json::from_str(json).unwrap();
152        assert_eq!(params.command, "echo hello");
153        assert!(!params.background);
154        assert!(params.timeout_ms.is_none());
155        assert!(params.log_path.is_none());
156    }
157
158    #[test]
159    fn test_exec_params_deserialize_background() {
160        let json = r#"{"command": "sleep 10", "background": true, "timeout_ms": 1000, "log_path": "/tmp/x.log"}"#;
161        let params: ExecParams = serde_json::from_str(json).unwrap();
162        assert_eq!(params.command, "sleep 10");
163        assert!(params.background);
164        assert_eq!(params.timeout_ms, Some(1000));
165        assert_eq!(params.log_path.as_deref(), Some("/tmp/x.log"));
166    }
167
168    #[test]
169    fn test_sudo_exec_params_deserialize() {
170        let json = r#"{"command": "apt update"}"#;
171        let params: SudoExecParams = serde_json::from_str(json).unwrap();
172        assert_eq!(params.command, "apt update");
173        assert!(!params.background);
174        assert!(params.timeout_ms.is_none());
175        assert!(params.log_path.is_none());
176    }
177
178    #[test]
179    fn test_check_process_params_deserialize() {
180        let json = r#"{"job_id": "job-123"}"#;
181        let params: CheckProcessParams = serde_json::from_str(json).unwrap();
182        assert_eq!(params.job_id, "job-123");
183        assert_eq!(params.tail_lines, 50);
184    }
185
186    #[test]
187    fn test_check_process_params_with_tail_lines() {
188        let json = r#"{"job_id": "job-123", "tail_lines": 100}"#;
189        let params: CheckProcessParams = serde_json::from_str(json).unwrap();
190        assert_eq!(params.job_id, "job-123");
191        assert_eq!(params.tail_lines, 100);
192    }
193
194    #[test]
195    fn test_read_file_params_deserialize() {
196        let json = r#"{"remote_path": "/etc/hosts"}"#;
197        let params: ReadFileParams = serde_json::from_str(json).unwrap();
198        assert_eq!(params.remote_path, "/etc/hosts");
199        assert_eq!(params.mode, ReadFileMode::Preview);
200        assert_eq!(params.lines, None);
201        assert!(params.timeout_ms.is_none());
202    }
203
204    #[test]
205    fn test_read_file_params_deserialize_with_timeout() {
206        let json = r#"{"remote_path": "/etc/hosts", "timeout_ms": 2500}"#;
207        let params: ReadFileParams = serde_json::from_str(json).unwrap();
208        assert_eq!(params.remote_path, "/etc/hosts");
209        assert_eq!(params.mode, ReadFileMode::Preview);
210        assert_eq!(params.lines, None);
211        assert_eq!(params.timeout_ms, Some(2500));
212    }
213
214    #[test]
215    fn test_read_file_params_deserialize_with_mode_and_lines() {
216        let json = r#"{"remote_path":"/etc/hosts","mode":"tail","lines":120}"#;
217        let params: ReadFileParams = serde_json::from_str(json).unwrap();
218        assert_eq!(params.remote_path, "/etc/hosts");
219        assert_eq!(params.mode, ReadFileMode::Tail);
220        assert_eq!(params.lines, Some(120));
221        assert!(params.timeout_ms.is_none());
222    }
223
224    #[test]
225    fn test_read_file_mode_serialization_is_lowercase() {
226        let value = serde_json::to_value(ReadFileMode::Full).unwrap();
227        assert_eq!(value, serde_json::json!("full"));
228    }
229
230    #[test]
231    fn test_apply_patch_params_deserialize_and_reject_unknown_fields() {
232        let json = r#"{"patch":"*** Begin Patch\n*** Delete File: /tmp/old\n*** End Patch"}"#;
233        let params: ApplyPatchParams = serde_json::from_str(json).unwrap();
234        assert!(params.patch.contains("*** Delete File"));
235
236        let err =
237            serde_json::from_str::<ApplyPatchParams>(r#"{"patch":"x","remote_path":"/tmp/x"}"#)
238                .unwrap_err();
239        assert!(err.to_string().contains("unknown field `remote_path`"));
240    }
241}