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//! - `exec` - Execute shell commands on the remote SSH server
8//! - `sudo-exec` - Execute shell commands with sudo privileges
9//! - `check-process` - Check if a process is still running and read its log
10//! - `read-file` - Read UTF-8 text files from the remote SSH server
11//! - `write-file` - Atomically overwrite or create a remote file
12//! - `replace-in-file` - Atomically replace text in a remote file
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
25fn default_read_file_mode() -> ReadFileMode {
26    ReadFileMode::Preview
27}
28
29/// Parameters for the exec tool
30#[derive(Debug, Deserialize, Serialize, JsonSchema)]
31pub struct ExecParams {
32    /// Shell command to execute on the remote SSH server
33    pub command: String,
34
35    /// Background execution mode.
36    ///
37    /// If true, run the command in background and return immediately.
38    /// The server continues streaming output into a local log file on the MCP server and
39    /// tracks the job via an in-memory registry keyed by job_id.
40    /// The tool returns JSON metadata (job_id/pid/log_path/log_exists).
41    #[serde(default)]
42    pub background: bool,
43
44    /// Optional timeout override in milliseconds for foreground execution
45    pub timeout_ms: Option<u64>,
46
47    /// Local log path for background mode output (stored on MCP server)
48    ///
49    /// Defaults to ssh-mcp/<job_id>.log in the system temp directory.
50    pub log_path: Option<String>,
51}
52
53/// Parameters for the sudo-exec tool
54#[derive(Debug, Deserialize, Serialize, JsonSchema)]
55pub struct SudoExecParams {
56    /// Shell command to execute with sudo on the remote SSH server
57    pub command: String,
58
59    /// Background execution mode.
60    ///
61    /// If true, run the command in background and return immediately.
62    /// The server continues streaming output into a local log file on the MCP server and
63    /// tracks the job via an in-memory registry keyed by job_id.
64    /// The tool returns JSON metadata (job_id/pid/log_path/log_exists).
65    #[serde(default)]
66    pub background: bool,
67
68    /// Optional timeout override in milliseconds for foreground execution
69    pub timeout_ms: Option<u64>,
70
71    /// Local log path for background mode output (stored on MCP server)
72    ///
73    /// Defaults to ssh-mcp/<job_id>.log in the system temp directory.
74    pub log_path: Option<String>,
75}
76
77/// Parameters for the check-process tool
78///
79/// # Migration from old API
80/// Previously required `pid` and `log_path`. Now uses `job_id` only.
81/// The job_id is returned by exec/sudo-exec when background=true.
82#[derive(Debug, Deserialize, Serialize, JsonSchema)]
83pub struct CheckProcessParams {
84    /// Job ID returned by exec/sudo-exec background execution
85    pub job_id: String,
86
87    /// Number of last lines to read from log (default: 50)
88    #[serde(default = "default_tail_lines")]
89    pub tail_lines: usize,
90}
91
92/// Parameters for the read-file tool
93#[derive(Debug, Clone, Copy, Deserialize, Serialize, JsonSchema, PartialEq, Eq)]
94#[serde(rename_all = "lowercase")]
95pub enum ReadFileMode {
96    /// Safe first-read mode that returns the first chunk of lines
97    Preview,
98    /// Return the first N lines
99    Head,
100    /// Return the last N lines
101    Tail,
102    /// Return the full file (subject to existing size safeguards)
103    Full,
104}
105
106impl ReadFileMode {
107    pub const fn as_str(self) -> &'static str {
108        match self {
109            Self::Preview => "preview",
110            Self::Head => "head",
111            Self::Tail => "tail",
112            Self::Full => "full",
113        }
114    }
115}
116
117#[derive(Debug, Deserialize, Serialize, JsonSchema)]
118pub struct ReadFileParams {
119    /// Absolute remote file path to read
120    pub remote_path: String,
121
122    /// Read mode (default: preview)
123    #[serde(default = "default_read_file_mode")]
124    pub mode: ReadFileMode,
125
126    /// Number of lines for preview/head/tail (default: 800)
127    pub lines: Option<usize>,
128
129    /// Optional timeout override in milliseconds
130    pub timeout_ms: Option<u64>,
131}
132
133/// Parameters for the write-file tool
134#[derive(Debug, Deserialize, Serialize, JsonSchema)]
135#[serde(deny_unknown_fields)]
136pub struct WriteFileParams {
137    /// Absolute remote file path to overwrite/create atomically
138    pub remote_path: String,
139
140    /// Full UTF-8 content that will replace the file atomically
141    pub new_content: String,
142
143    /// Optional SHA-256 precondition for optimistic locking
144    pub expected_sha256: Option<String>,
145
146    /// Opaque read-ticket from read-file response (required for editing non-empty existing files)
147    pub read_ticket: Option<String>,
148
149    /// Return a diff preview without mutating the remote file
150    pub dry_run: Option<bool>,
151
152    /// Optional timeout override in milliseconds
153    pub timeout_ms: Option<u64>,
154}
155
156/// Parameters for the replace-in-file tool
157#[derive(Debug, Deserialize, Serialize, JsonSchema)]
158#[serde(deny_unknown_fields)]
159pub struct ReplaceInFileParams {
160    /// Absolute remote file path to edit in place
161    pub remote_path: String,
162
163    /// Source text to replace in the current file
164    pub old_text: String,
165
166    /// Replacement text used for the edit
167    pub new_text: String,
168
169    /// Optional exact scope that must match once; replacement is limited to this substring
170    pub scope_text: Option<String>,
171
172    /// Replace all matches when true (default false)
173    pub replace_all: Option<bool>,
174
175    /// 1-based match selector used when old_text appears multiple times
176    pub match_index: Option<usize>,
177
178    /// Return a diff preview without mutating the remote file
179    pub dry_run: Option<bool>,
180
181    /// Optional SHA-256 precondition for optimistic locking
182    pub expected_sha256: Option<String>,
183
184    /// Optional timeout override in milliseconds
185    pub timeout_ms: Option<u64>,
186}
187
188fn default_tail_lines() -> usize {
189    DEFAULT_CHECK_PROCESS_TAIL_LINES
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_exec_params_deserialize() {
198        let json = r#"{"command": "echo hello"}"#;
199        let params: ExecParams = serde_json::from_str(json).unwrap();
200        assert_eq!(params.command, "echo hello");
201        assert!(!params.background);
202        assert!(params.timeout_ms.is_none());
203        assert!(params.log_path.is_none());
204    }
205
206    #[test]
207    fn test_exec_params_deserialize_background() {
208        let json = r#"{"command": "sleep 10", "background": true, "timeout_ms": 1000, "log_path": "/tmp/x.log"}"#;
209        let params: ExecParams = serde_json::from_str(json).unwrap();
210        assert_eq!(params.command, "sleep 10");
211        assert!(params.background);
212        assert_eq!(params.timeout_ms, Some(1000));
213        assert_eq!(params.log_path.as_deref(), Some("/tmp/x.log"));
214    }
215
216    #[test]
217    fn test_sudo_exec_params_deserialize() {
218        let json = r#"{"command": "apt update"}"#;
219        let params: SudoExecParams = serde_json::from_str(json).unwrap();
220        assert_eq!(params.command, "apt update");
221        assert!(!params.background);
222        assert!(params.timeout_ms.is_none());
223        assert!(params.log_path.is_none());
224    }
225
226    #[test]
227    fn test_check_process_params_deserialize() {
228        let json = r#"{"job_id": "job-123"}"#;
229        let params: CheckProcessParams = serde_json::from_str(json).unwrap();
230        assert_eq!(params.job_id, "job-123");
231        assert_eq!(params.tail_lines, 50);
232    }
233
234    #[test]
235    fn test_check_process_params_with_tail_lines() {
236        let json = r#"{"job_id": "job-123", "tail_lines": 100}"#;
237        let params: CheckProcessParams = serde_json::from_str(json).unwrap();
238        assert_eq!(params.job_id, "job-123");
239        assert_eq!(params.tail_lines, 100);
240    }
241
242    #[test]
243    fn test_read_file_params_deserialize() {
244        let json = r#"{"remote_path": "/etc/hosts"}"#;
245        let params: ReadFileParams = serde_json::from_str(json).unwrap();
246        assert_eq!(params.remote_path, "/etc/hosts");
247        assert_eq!(params.mode, ReadFileMode::Preview);
248        assert_eq!(params.lines, None);
249        assert!(params.timeout_ms.is_none());
250    }
251
252    #[test]
253    fn test_read_file_params_deserialize_with_timeout() {
254        let json = r#"{"remote_path": "/etc/hosts", "timeout_ms": 2500}"#;
255        let params: ReadFileParams = serde_json::from_str(json).unwrap();
256        assert_eq!(params.remote_path, "/etc/hosts");
257        assert_eq!(params.mode, ReadFileMode::Preview);
258        assert_eq!(params.lines, None);
259        assert_eq!(params.timeout_ms, Some(2500));
260    }
261
262    #[test]
263    fn test_read_file_params_deserialize_with_mode_and_lines() {
264        let json = r#"{"remote_path":"/etc/hosts","mode":"tail","lines":120}"#;
265        let params: ReadFileParams = serde_json::from_str(json).unwrap();
266        assert_eq!(params.remote_path, "/etc/hosts");
267        assert_eq!(params.mode, ReadFileMode::Tail);
268        assert_eq!(params.lines, Some(120));
269        assert!(params.timeout_ms.is_none());
270    }
271
272    #[test]
273    fn test_read_file_mode_serialization_is_lowercase() {
274        let value = serde_json::to_value(ReadFileMode::Full).unwrap();
275        assert_eq!(value, serde_json::json!("full"));
276    }
277
278    #[test]
279    fn test_write_file_params_deserialize() {
280        let json = r#"{"remote_path":"/etc/hosts","new_content":"127.0.0.1 localhost\n"}"#;
281        let params: WriteFileParams = serde_json::from_str(json).unwrap();
282        assert_eq!(params.remote_path, "/etc/hosts");
283        assert_eq!(params.new_content, "127.0.0.1 localhost\n");
284        assert!(params.expected_sha256.is_none());
285        assert!(params.read_ticket.is_none());
286        assert!(params.dry_run.is_none());
287        assert!(params.timeout_ms.is_none());
288    }
289
290    #[test]
291    fn test_write_file_params_deserialize_with_expected_hash_and_timeout() {
292        let json = r#"{"remote_path":"/etc/hosts","new_content":"x","expected_sha256":"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff","timeout_ms":4000}"#;
293        let params: WriteFileParams = serde_json::from_str(json).unwrap();
294        assert_eq!(params.remote_path, "/etc/hosts");
295        assert_eq!(params.new_content, "x");
296        assert_eq!(
297            params.expected_sha256.as_deref(),
298            Some("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
299        );
300        assert!(params.read_ticket.is_none());
301        assert!(params.dry_run.is_none());
302        assert_eq!(params.timeout_ms, Some(4000));
303    }
304
305    #[test]
306    fn test_replace_in_file_params_deserialize_defaults_replace_all() {
307        let json = r#"{"remote_path":"/etc/hosts","old_text":"127.0.0.1","new_text":"127.0.0.2"}"#;
308        let params: ReplaceInFileParams = serde_json::from_str(json).unwrap();
309        assert_eq!(params.remote_path, "/etc/hosts");
310        assert_eq!(params.old_text, "127.0.0.1");
311        assert_eq!(params.new_text, "127.0.0.2");
312        assert!(params.scope_text.is_none());
313        assert!(params.replace_all.is_none());
314        assert!(params.match_index.is_none());
315        assert!(params.dry_run.is_none());
316        assert!(params.expected_sha256.is_none());
317        assert!(params.timeout_ms.is_none());
318    }
319
320    #[test]
321    fn test_replace_in_file_params_deserialize_replace_all_true() {
322        let json = r#"{"remote_path":"/etc/hosts","old_text":"x","new_text":"y","scope_text":"block","replace_all":true,"match_index":3,"dry_run":true,"timeout_ms":2000}"#;
323        let params: ReplaceInFileParams = serde_json::from_str(json).unwrap();
324        assert_eq!(params.remote_path, "/etc/hosts");
325        assert_eq!(params.old_text, "x");
326        assert_eq!(params.new_text, "y");
327        assert_eq!(params.scope_text.as_deref(), Some("block"));
328        assert_eq!(params.replace_all, Some(true));
329        assert_eq!(params.match_index, Some(3));
330        assert_eq!(params.dry_run, Some(true));
331        assert_eq!(params.timeout_ms, Some(2000));
332    }
333
334    #[test]
335    fn test_write_file_params_reject_unknown_fields() {
336        let json = r#"{"remote_path":"/etc/hosts","new_content":"x","old_text":"y"}"#;
337        let err = serde_json::from_str::<WriteFileParams>(json).unwrap_err();
338        assert!(err.to_string().contains("unknown field `old_text`"));
339    }
340
341    #[test]
342    fn test_replace_in_file_params_reject_unknown_fields() {
343        let json =
344            r#"{"remote_path":"/etc/hosts","old_text":"x","new_text":"y","read_ticket":"rt1.x"}"#;
345        let err = serde_json::from_str::<ReplaceInFileParams>(json).unwrap_err();
346        assert!(err.to_string().contains("unknown field `read_ticket`"));
347    }
348}