1use clap::Parser;
4use std::path::PathBuf;
5
6use crate::error::{Result, SshMcpError};
7use crate::ssh::HostKeyCheckMode;
8
9pub const DEFAULT_TIMEOUT_MS: u64 = 300_000; pub const DEFAULT_MAX_CHARS: Option<usize> = Some(64_000);
14
15pub const CONNECTION_TIMEOUT_SECS: u64 = 30;
17
18pub const DEFAULT_RECONNECT_RETRIES: u64 = 3;
20
21pub const DEFAULT_RECONNECT_BACKOFF_MS: u64 = 250;
23
24pub const DEFAULT_HEALTH_PROBE_TIMEOUT_MS: u64 = 1500;
26
27pub const MAX_RECONNECT_RETRIES: u64 = 10;
29
30pub const MIN_RECONNECT_BACKOFF_MS: u64 = 10;
32
33pub const MAX_RECONNECT_BACKOFF_MS: u64 = 30_000;
35
36pub const MIN_HEALTH_PROBE_TIMEOUT_MS: u64 = 100;
38
39pub const MAX_HEALTH_PROBE_TIMEOUT_MS: u64 = 30_000;
41
42#[derive(Parser, Debug, Clone)]
44#[command(name = "ssh-mcp")]
45#[command(author = "0FL01")]
46#[command(version = env!("CARGO_PKG_VERSION"))]
47#[command(about = "MCP server exposing SSH control for Linux systems via Model Context Protocol")]
48pub struct Args {
49 #[arg(long, env = "SSH_MCP_HOST")]
51 pub host: String,
52
53 #[arg(long, default_value = "22", env = "SSH_MCP_PORT")]
55 pub port: u16,
56
57 #[arg(long, env = "SSH_MCP_USER")]
59 pub user: String,
60
61 #[arg(long, env = "SSH_MCP_PASSWORD")]
63 pub password: Option<String>,
64
65 #[arg(long, env = "SSH_MCP_KEY")]
67 pub key: Option<PathBuf>,
68
69 #[arg(long, env = "SSH_MCP_SU_PASSWORD")]
71 pub su_password: Option<String>,
72
73 #[arg(long, env = "SSH_MCP_SUDO_PASSWORD")]
75 pub sudo_password: Option<String>,
76
77 #[arg(long, default_value = "300000", env = "SSH_MCP_TIMEOUT")]
79 pub timeout: u64,
80
81 #[arg(long = "maxChars", env = "SSH_MCP_MAX_CHARS")]
85 pub max_chars: Option<String>,
86
87 #[arg(long, default_value = "false", env = "SSH_MCP_DISABLE_SUDO")]
89 pub disable_sudo: bool,
90
91 #[arg(long = "max-output-tokens", env = "SSH_MCP_MAX_OUTPUT_TOKENS")]
96 pub max_output_tokens: Option<String>,
97
98 #[arg(long, default_value = "info", env = "SSH_MCP_LOG_LEVEL", value_parser = clap::builder::PossibleValuesParser::new(["trace", "debug", "info", "warn", "error"]))]
100 pub log_level: String,
101
102 #[arg(long, env = "SSH_MCP_LOG_FILE")]
104 pub log_file: Option<PathBuf>,
105
106 #[arg(long, default_value = "text", env = "SSH_MCP_LOG_FORMAT", value_parser = clap::builder::PossibleValuesParser::new(["text", "json"]))]
108 pub log_format: String,
109
110 #[arg(long, default_value = "daily", env = "SSH_MCP_LOG_ROTATION", value_parser = clap::builder::PossibleValuesParser::new(["daily", "hourly", "never"]))]
112 pub log_rotation: String,
113
114 #[arg(long, default_value = "30", env = "SSH_MCP_KEEPALIVE_INTERVAL")]
117 pub keepalive_interval: u64,
118
119 #[arg(long, default_value = "3", env = "SSH_MCP_KEEPALIVE_MAX")]
122 pub keepalive_max: u64,
123
124 #[arg(long, default_value = "3", env = "SSH_MCP_RECONNECT_RETRIES")]
126 pub reconnect_retries: u64,
127
128 #[arg(long, default_value = "250", env = "SSH_MCP_RECONNECT_BACKOFF_MS")]
130 pub reconnect_backoff_ms: u64,
131
132 #[arg(long, default_value = "1500", env = "SSH_MCP_HEALTH_PROBE_TIMEOUT_MS")]
134 pub health_probe_timeout_ms: u64,
135
136 #[arg(
138 long = "strict-host-key-checking",
139 env = "SSH_MCP_STRICT_HOST_KEY_CHECKING",
140 value_enum,
141 default_value_t = HostKeyCheckMode::AcceptNew
142 )]
143 pub strict_host_key_checking: HostKeyCheckMode,
144
145 #[arg(long = "known-hosts", env = "SSH_MCP_KNOWN_HOSTS")]
147 pub known_hosts: Option<PathBuf>,
148}
149
150#[derive(Debug, Clone)]
152pub struct Config {
153 pub host: String,
155
156 pub port: u16,
158
159 pub user: String,
161
162 pub password: Option<String>,
164
165 pub key: Option<PathBuf>,
167
168 pub su_password: Option<String>,
170
171 pub sudo_password: Option<String>,
173
174 pub timeout_ms: u64,
176
177 pub max_chars: Option<usize>,
179
180 pub max_output_tokens: Option<usize>,
182
183 pub disable_sudo: bool,
185
186 pub keepalive_interval: u64,
188
189 pub keepalive_max: u64,
191
192 pub reconnect_retries: u64,
194
195 pub reconnect_backoff_ms: u64,
197
198 pub health_probe_timeout_ms: u64,
200
201 pub strict_host_key_checking: HostKeyCheckMode,
203
204 pub known_hosts: Option<PathBuf>,
206}
207
208impl Config {
209 pub fn from_args(args: Args) -> Result<Self> {
211 validate_args(&args)?;
212
213 let max_chars = parse_max_chars(args.max_chars.as_deref());
214 let max_output_tokens = parse_max_output_tokens(args.max_output_tokens.as_deref());
215
216 Ok(Config {
217 host: args.host,
218 port: args.port,
219 user: args.user,
220 password: sanitize_password(args.password),
221 key: args.key,
222 su_password: sanitize_password(args.su_password),
223 sudo_password: sanitize_password(args.sudo_password),
224 timeout_ms: args.timeout,
225 max_chars,
226 max_output_tokens,
227 disable_sudo: args.disable_sudo,
228 keepalive_interval: args.keepalive_interval,
229 keepalive_max: args.keepalive_max,
230 reconnect_retries: args.reconnect_retries,
231 reconnect_backoff_ms: args.reconnect_backoff_ms,
232 health_probe_timeout_ms: args.health_probe_timeout_ms,
233 strict_host_key_checking: args.strict_host_key_checking,
234 known_hosts: args.known_hosts,
235 })
236 }
237}
238
239fn validate_args(args: &Args) -> Result<()> {
241 let mut errors = Vec::new();
242
243 if args.host.is_empty() {
244 errors.push("Missing required --host".to_string());
245 }
246
247 if args.user.is_empty() {
248 errors.push("Missing required --user".to_string());
249 }
250
251 if args.password.is_none() && args.key.is_none() {
253 errors.push("Must provide either --password or --key".to_string());
254 }
255
256 if let Some(ref key_path) = args.key
258 && !key_path.exists()
259 {
260 errors.push(format!("SSH key file not found: {}", key_path.display()));
261 }
262
263 if args.reconnect_retries > MAX_RECONNECT_RETRIES {
264 errors.push(format!(
265 "--reconnect-retries must be <= {MAX_RECONNECT_RETRIES}"
266 ));
267 }
268
269 if !(MIN_RECONNECT_BACKOFF_MS..=MAX_RECONNECT_BACKOFF_MS).contains(&args.reconnect_backoff_ms) {
270 errors.push(format!(
271 "--reconnect-backoff-ms must be between {MIN_RECONNECT_BACKOFF_MS} and {MAX_RECONNECT_BACKOFF_MS}"
272 ));
273 }
274
275 if !(MIN_HEALTH_PROBE_TIMEOUT_MS..=MAX_HEALTH_PROBE_TIMEOUT_MS)
276 .contains(&args.health_probe_timeout_ms)
277 {
278 errors.push(format!(
279 "--health-probe-timeout-ms must be between {MIN_HEALTH_PROBE_TIMEOUT_MS} and {MAX_HEALTH_PROBE_TIMEOUT_MS}"
280 ));
281 }
282
283 if !errors.is_empty() {
284 return Err(SshMcpError::Config(format!(
285 "Configuration error:\n{}",
286 errors.join("\n")
287 )));
288 }
289
290 Ok(())
291}
292
293pub const DEFAULT_MAX_OUTPUT_TOKENS: Option<usize> = Some(16_000);
295
296pub fn parse_max_chars(value: Option<&str>) -> Option<usize> {
303 match value {
304 None => DEFAULT_MAX_CHARS,
305 Some(s) => {
306 let lowered = s.to_lowercase();
307 if lowered == "none" {
308 return None;
309 }
310
311 match s.parse::<i64>() {
312 Ok(n) if n <= 0 => None,
313 Ok(n) => Some(n as usize),
314 Err(_) => DEFAULT_MAX_CHARS,
315 }
316 }
317 }
318}
319
320pub fn parse_max_output_tokens(value: Option<&str>) -> Option<usize> {
327 match value {
328 None => DEFAULT_MAX_OUTPUT_TOKENS,
329 Some(s) => {
330 let lowered = s.to_lowercase().replace(" ", "");
331 if lowered == "none" {
332 return None;
333 }
334
335 if lowered.ends_with('k') {
337 let num_part = &lowered[..lowered.len() - 1];
338 match num_part.parse::<i64>() {
339 Ok(n) if n <= 0 => None,
340 Ok(n) => Some((n as usize).saturating_mul(1_000)),
341 Err(_) => DEFAULT_MAX_OUTPUT_TOKENS,
342 }
343 } else {
344 match lowered.parse::<i64>() {
345 Ok(n) if n <= 0 => None,
346 Ok(n) => Some(n as usize),
347 Err(_) => DEFAULT_MAX_OUTPUT_TOKENS,
348 }
349 }
350 }
351 }
352}
353
354fn sanitize_password(password: Option<String>) -> Option<String> {
356 password.filter(|p| !p.is_empty())
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362
363 fn base_args() -> Args {
364 Args {
365 host: "localhost".to_string(),
366 port: 22,
367 user: "test".to_string(),
368 password: Some("secret".to_string()),
369 key: None,
370 su_password: None,
371 sudo_password: None,
372 timeout: DEFAULT_TIMEOUT_MS,
373 max_chars: None,
374 disable_sudo: false,
375 max_output_tokens: None,
376 log_level: "info".to_string(),
377 log_file: None,
378 log_format: "text".to_string(),
379 log_rotation: "daily".to_string(),
380 keepalive_interval: 30,
381 keepalive_max: 3,
382 reconnect_retries: DEFAULT_RECONNECT_RETRIES,
383 reconnect_backoff_ms: DEFAULT_RECONNECT_BACKOFF_MS,
384 health_probe_timeout_ms: DEFAULT_HEALTH_PROBE_TIMEOUT_MS,
385 strict_host_key_checking: HostKeyCheckMode::AcceptNew,
386 known_hosts: None,
387 }
388 }
389
390 #[test]
391 fn test_parse_max_chars_none_string() {
392 assert_eq!(parse_max_chars(Some("none")), None);
393 assert_eq!(parse_max_chars(Some("None")), None);
394 assert_eq!(parse_max_chars(Some("NONE")), None);
395 }
396
397 #[test]
398 fn test_parse_max_chars_zero_or_negative() {
399 assert_eq!(parse_max_chars(Some("0")), None);
400 assert_eq!(parse_max_chars(Some("-1")), None);
401 assert_eq!(parse_max_chars(Some("-100")), None);
402 }
403
404 #[test]
405 fn test_parse_max_chars_positive() {
406 assert_eq!(parse_max_chars(Some("500")), Some(500));
407 assert_eq!(parse_max_chars(Some("2000")), Some(2000));
408 }
409
410 #[test]
411 fn test_parse_max_chars_invalid() {
412 assert_eq!(parse_max_chars(Some("abc")), DEFAULT_MAX_CHARS);
414 assert_eq!(parse_max_chars(Some("")), DEFAULT_MAX_CHARS);
415 }
416
417 #[test]
418 fn test_parse_max_chars_not_provided() {
419 assert_eq!(parse_max_chars(None), DEFAULT_MAX_CHARS);
420 }
421
422 #[test]
423 fn test_config_from_args_uses_default_max_chars() {
424 let config = Config::from_args(base_args()).unwrap();
425
426 assert_eq!(config.max_chars, Some(64_000));
427 assert_eq!(config.strict_host_key_checking, HostKeyCheckMode::AcceptNew);
428 assert!(config.known_hosts.is_none());
429 }
430
431 #[test]
432 fn test_args_parse_host_key_options() {
433 let args = Args::try_parse_from([
434 "ssh-mcp",
435 "--host",
436 "example.com",
437 "--user",
438 "alice",
439 "--password",
440 "secret",
441 "--strict-host-key-checking",
442 "yes",
443 "--known-hosts",
444 "/tmp/known_hosts",
445 ])
446 .unwrap();
447
448 assert_eq!(args.strict_host_key_checking, HostKeyCheckMode::Yes);
449 assert_eq!(args.known_hosts, Some(PathBuf::from("/tmp/known_hosts")));
450 }
451
452 #[test]
453 fn test_sanitize_password() {
454 assert_eq!(
455 sanitize_password(Some("secret".to_string())),
456 Some("secret".to_string())
457 );
458 assert_eq!(sanitize_password(Some(String::new())), None);
459 assert_eq!(sanitize_password(None), None);
460 }
461
462 #[test]
463 fn test_parse_max_output_tokens_none_string() {
464 assert_eq!(parse_max_output_tokens(Some("none")), None);
465 assert_eq!(parse_max_output_tokens(Some("None")), None);
466 assert_eq!(parse_max_output_tokens(Some("NONE")), None);
467 }
468
469 #[test]
470 fn test_parse_max_output_tokens_zero_or_negative() {
471 assert_eq!(parse_max_output_tokens(Some("0")), None);
472 assert_eq!(parse_max_output_tokens(Some("-1")), None);
473 assert_eq!(parse_max_output_tokens(Some("-100")), None);
474 }
475
476 #[test]
477 fn test_parse_max_output_tokens_positive() {
478 assert_eq!(parse_max_output_tokens(Some("500")), Some(500));
479 assert_eq!(parse_max_output_tokens(Some("12000")), Some(12_000));
480 }
481
482 #[test]
483 fn test_parse_max_output_tokens_with_k_suffix() {
484 assert_eq!(parse_max_output_tokens(Some("12k")), Some(12_000));
485 assert_eq!(parse_max_output_tokens(Some("5K")), Some(5_000));
486 assert_eq!(parse_max_output_tokens(Some("100k")), Some(100_000));
487 }
488
489 #[test]
490 fn test_parse_max_output_tokens_invalid() {
491 assert_eq!(
493 parse_max_output_tokens(Some("abc")),
494 DEFAULT_MAX_OUTPUT_TOKENS
495 );
496 assert_eq!(parse_max_output_tokens(Some("")), DEFAULT_MAX_OUTPUT_TOKENS);
497 }
498
499 #[test]
500 fn test_parse_max_output_tokens_not_provided() {
501 assert_eq!(parse_max_output_tokens(None), DEFAULT_MAX_OUTPUT_TOKENS);
502 }
503
504 #[test]
505 fn test_validate_args_rejects_reconnect_retries_out_of_range() {
506 let mut args = base_args();
507 args.reconnect_retries = MAX_RECONNECT_RETRIES.saturating_add(1);
508
509 let result = validate_args(&args);
510 assert!(result.is_err());
511 }
512
513 #[test]
514 fn test_validate_args_rejects_reconnect_backoff_out_of_range() {
515 let mut args = base_args();
516 args.reconnect_backoff_ms = MIN_RECONNECT_BACKOFF_MS.saturating_sub(1);
517
518 let result = validate_args(&args);
519 assert!(result.is_err());
520 }
521
522 #[test]
523 fn test_validate_args_rejects_health_probe_timeout_out_of_range() {
524 let mut args = base_args();
525 args.health_probe_timeout_ms = MAX_HEALTH_PROBE_TIMEOUT_MS.saturating_add(1);
526
527 let result = validate_args(&args);
528 assert!(result.is_err());
529 }
530}