Skip to main content

agent_graph_mcp/
cli.rs

1//! Strict CLI argument parsing for agent-graph-mcp.
2//!
3//! Replaces the silent `_ => {}` pattern that allowed typos like `--dat-dir`
4//! to launch with unsafe defaults. Every unknown flag, missing value, or
5//! malformed URL must exit nonzero before MCP transport starts.
6
7use std::path::PathBuf;
8
9use crate::spec::{validate_max_graphs, DEFAULT_MAX_GRAPHS};
10
11/// Parsed CLI configuration.
12#[derive(Debug, Clone)]
13pub struct CliConfig {
14    pub base_url: String,
15    pub default_model: String,
16    pub data_dir: Option<PathBuf>,
17    pub integrity_key_path: Option<PathBuf>,
18    pub require_integrity_key: bool,
19    pub ephemeral: bool,
20    pub max_graphs: usize,
21}
22
23impl Default for CliConfig {
24    fn default() -> Self {
25        Self {
26            base_url: "http://127.0.0.1:11434".to_string(),
27            default_model: "glm-5.2:cloud".to_string(),
28            data_dir: None,
29            integrity_key_path: None,
30            require_integrity_key: false,
31            ephemeral: false,
32            max_graphs: DEFAULT_MAX_GRAPHS,
33        }
34    }
35}
36
37/// Typed CLI parse error. The caller must exit nonzero and must not start
38/// MCP transport.
39#[derive(Debug, Clone)]
40pub struct CliError {
41    pub message: String,
42    pub exit_code: i32,
43}
44
45impl CliError {
46    pub fn new(message: impl Into<String>) -> Self {
47        Self {
48            message: message.into(),
49            exit_code: 2,
50        }
51    }
52}
53
54impl std::fmt::Display for CliError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        write!(f, "{}", self.message)
57    }
58}
59
60impl std::error::Error for CliError {}
61
62#[derive(Debug, Clone)]
63pub struct ProxyConfig {
64    pub socket: PathBuf,
65    pub timeout_ms: u64,
66}
67pub fn parse_proxy_args(args: &[String]) -> Result<ProxyConfig, CliError> {
68    let mut socket = std::env::var_os("XDG_RUNTIME_DIR")
69        .map(PathBuf::from)
70        .unwrap_or_else(|| PathBuf::from("/tmp"))
71        .join("agent-graph/mcp.sock");
72    let mut timeout_ms = 2000;
73    let mut i = 0;
74    while i < args.len() {
75        match args[i].as_str() {
76            "--help" => {
77                return Err(CliError {
78                    message: "agent-graph-mcp [--socket PATH] [--connect-timeout-ms N]".into(),
79                    exit_code: 0,
80                });
81            }
82            "--version" => {
83                return Err(CliError {
84                    message: env!("CARGO_PKG_VERSION").into(),
85                    exit_code: 0,
86                });
87            }
88            "--socket" => {
89                i += 1;
90                socket = PathBuf::from(
91                    args.get(i)
92                        .ok_or_else(|| CliError::new("--socket requires a value"))?,
93                );
94            }
95            "--connect-timeout-ms" => {
96                i += 1;
97                timeout_ms = args
98                    .get(i)
99                    .ok_or_else(|| CliError::new("--connect-timeout-ms requires a value"))?
100                    .parse()
101                    .map_err(|_| CliError::new("invalid timeout"))?;
102            }
103            "--data-dir" | "--integrity-key" | "--base-url" | "--model" | "--max-graphs" => {
104                return Err(CliError::new("LEGACY_DIRECT_DURABLE_UNSUPPORTED"));
105            }
106            other => return Err(CliError::new(format!("unknown argument: '{other}'"))),
107        }
108        i += 1;
109    }
110    Ok(ProxyConfig { socket, timeout_ms })
111}
112
113///
114/// Strict rules:
115/// - Unknown flags are rejected.
116/// - Flags requiring a value must have one.
117/// - `--data-dir` implies durable mode; `--ephemeral` is explicit memory-only.
118/// - `--require-integrity-key` without a readable key path is rejected at
119///   parse time if the path is provided here; otherwise the caller validates
120///   file existence/length after parsing.
121/// - Provider URLs must be http or https.
122pub fn parse_args(args: &[String]) -> Result<CliConfig, CliError> {
123    let mut config = CliConfig::default();
124    let mut iter = args.iter().peekable();
125
126    while let Some(arg) = iter.next() {
127        match arg.as_str() {
128            "--base-url" => {
129                let value = iter
130                    .next()
131                    .ok_or_else(|| CliError::new("--base-url requires a value"))?;
132                validate_url(value)?;
133                config.base_url = value.clone();
134            }
135            "--model" => {
136                let value = iter
137                    .next()
138                    .ok_or_else(|| CliError::new("--model requires a value"))?;
139                if value.is_empty() {
140                    return Err(CliError::new("--model value must not be empty"));
141                }
142                config.default_model = value.clone();
143            }
144            "--data-dir" => {
145                let value = iter
146                    .next()
147                    .ok_or_else(|| CliError::new("--data-dir requires a value"))?;
148                if value.is_empty() {
149                    return Err(CliError::new("--data-dir value must not be empty"));
150                }
151                config.data_dir = Some(PathBuf::from(value));
152            }
153            "--integrity-key" => {
154                let value = iter
155                    .next()
156                    .ok_or_else(|| CliError::new("--integrity-key requires a value"))?;
157                if value.is_empty() {
158                    return Err(CliError::new("--integrity-key value must not be empty"));
159                }
160                config.integrity_key_path = Some(PathBuf::from(value));
161            }
162            "--require-integrity-key" => {
163                config.require_integrity_key = true;
164            }
165            "--ephemeral" => {
166                config.ephemeral = true;
167            }
168            "--max-graphs" => {
169                let value = iter
170                    .next()
171                    .ok_or_else(|| CliError::new("--max-graphs requires a value"))?;
172                let max_graphs = value
173                    .parse::<usize>()
174                    .map_err(|_| CliError::new("--max-graphs must be an integer"))?;
175                config.max_graphs = validate_max_graphs(max_graphs).map_err(CliError::new)?;
176            }
177            "--help" => {
178                eprintln!("agent-graph-mcp [OPTIONS]");
179                eprintln!();
180                eprintln!("Options:");
181                eprintln!(
182                    "  --base-url <url>         Provider URL (default: http://127.0.0.1:11434)"
183                );
184                eprintln!(
185                    "  --model <name>           Default model for LLM nodes (default: glm-5.2:cloud)"
186                );
187                eprintln!("  --data-dir <path>        Persistent storage directory");
188                eprintln!("  --integrity-key <path>   Integrity key file for durable mode");
189                eprintln!(
190                    "  --require-integrity-key  Fail startup if integrity key is missing/unreadable"
191                );
192                eprintln!("  --ephemeral              Explicit in-memory mode (no persistence)");
193                eprintln!(
194                    "  --max-graphs <n>         Registered graph capacity (1-1024; default: 64)"
195                );
196                eprintln!("  --help                   Show this help message");
197                return Err(CliError {
198                    message: String::new(),
199                    exit_code: 0,
200                });
201            }
202            _ => {
203                return Err(CliError::new(format!(
204                    "unknown argument: '{arg}' — use --help for usage"
205                )));
206            }
207        }
208    }
209
210    // Cross-flag validation
211    if config.ephemeral && config.data_dir.is_some() {
212        return Err(CliError::new(
213            "--ephemeral and --data-dir are mutually exclusive",
214        ));
215    }
216
217    if config.require_integrity_key && config.data_dir.is_none() {
218        return Err(CliError::new(
219            "--require-integrity-key requires --data-dir (durable mode)",
220        ));
221    }
222
223    Ok(config)
224}
225
226/// Validate that a URL has http or https scheme and a non-empty host.
227fn validate_url(url: &str) -> Result<(), CliError> {
228    let (scheme, rest) = url
229        .split_once("://")
230        .ok_or_else(|| CliError::new(format!("invalid URL (no scheme): {url}")))?;
231
232    if scheme != "http" && scheme != "https" {
233        return Err(CliError::new(format!(
234            "unsupported URL scheme '{scheme}': only http and https are allowed"
235        )));
236    }
237
238    let authority = rest.split_once('/').map(|(auth, _)| auth).unwrap_or(rest);
239    let host = authority
240        .rsplit_once('@')
241        .map(|(_, host)| host)
242        .unwrap_or(authority);
243
244    if host.is_empty() {
245        return Err(CliError::new(format!("invalid URL (empty host): {url}")));
246    }
247
248    Ok(())
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254
255    fn args(flags: &[&str]) -> Vec<String> {
256        flags.iter().map(|s| s.to_string()).collect()
257    }
258
259    #[test]
260    fn test_unknown_flag_rejected() {
261        let result = parse_args(&args(&["--dat-dir", "/secure/path"]));
262        assert!(result.is_err());
263        assert!(result.unwrap_err().message.contains("unknown argument"));
264    }
265
266    #[test]
267    fn test_missing_value_for_base_url() {
268        let result = parse_args(&args(&["--base-url"]));
269        assert!(result.is_err());
270        assert!(result.unwrap_err().message.contains("requires a value"));
271    }
272
273    #[test]
274    fn test_missing_value_for_model() {
275        let result = parse_args(&args(&["--model"]));
276        assert!(result.is_err());
277        assert!(result.unwrap_err().message.contains("requires a value"));
278    }
279
280    #[test]
281    fn test_missing_value_for_data_dir() {
282        let result = parse_args(&args(&["--data-dir"]));
283        assert!(result.is_err());
284        assert!(result.unwrap_err().message.contains("requires a value"));
285    }
286
287    #[test]
288    fn test_empty_model_value_rejected() {
289        let result = parse_args(&args(&["--model", ""]));
290        assert!(result.is_err());
291        assert!(result.unwrap_err().message.contains("must not be empty"));
292    }
293
294    #[test]
295    fn test_empty_data_dir_value_rejected() {
296        let result = parse_args(&args(&["--data-dir", ""]));
297        assert!(result.is_err());
298        assert!(result.unwrap_err().message.contains("must not be empty"));
299    }
300
301    #[test]
302    fn test_non_http_url_rejected() {
303        let result = parse_args(&args(&["--base-url", "ftp://example.com"]));
304        assert!(result.is_err());
305        assert!(result
306            .unwrap_err()
307            .message
308            .contains("unsupported URL scheme"));
309    }
310
311    #[test]
312    fn test_no_scheme_url_rejected() {
313        let result = parse_args(&args(&["--base-url", "example.com"]));
314        assert!(result.is_err());
315        assert!(result.unwrap_err().message.contains("no scheme"));
316    }
317
318    #[test]
319    fn test_empty_host_url_rejected() {
320        let result = parse_args(&args(&["--base-url", "http://"]));
321        assert!(result.is_err());
322        assert!(result.unwrap_err().message.contains("empty host"));
323    }
324
325    #[test]
326    fn test_valid_http_url_accepted() {
327        let result = parse_args(&args(&["--base-url", "http://127.0.0.1:11434"]));
328        assert!(result.is_ok());
329        assert_eq!(result.unwrap().base_url, "http://127.0.0.1:11434");
330    }
331
332    #[test]
333    fn test_valid_https_url_accepted() {
334        let result = parse_args(&args(&["--base-url", "https://api.openai.com"]));
335        assert!(result.is_ok());
336        assert_eq!(result.unwrap().base_url, "https://api.openai.com");
337    }
338
339    #[test]
340    fn test_url_with_credentials_stripped_in_validation() {
341        let result = parse_args(&args(&["--base-url", "https://user:pass@host.com"]));
342        assert!(result.is_ok()); // validation passes; runtime redaction is separate
343    }
344
345    #[test]
346    fn test_ephemeral_and_data_dir_mutually_exclusive() {
347        let result = parse_args(&args(&["--ephemeral", "--data-dir", "/tmp/test"]));
348        assert!(result.is_err());
349        assert!(result.unwrap_err().message.contains("mutually exclusive"));
350    }
351
352    #[test]
353    fn test_require_integrity_key_without_data_dir_rejected() {
354        let result = parse_args(&args(&["--require-integrity-key"]));
355        assert!(result.is_err());
356        assert!(result.unwrap_err().message.contains("requires --data-dir"));
357    }
358
359    #[test]
360    fn test_require_integrity_key_with_data_dir_accepted() {
361        let result = parse_args(&args(&[
362            "--require-integrity-key",
363            "--data-dir",
364            "/tmp/test",
365            "--integrity-key",
366            "/tmp/key",
367        ]));
368        assert!(result.is_ok());
369        assert!(result.unwrap().require_integrity_key);
370    }
371
372    #[test]
373    fn test_help_returns_exit_zero() {
374        let result = parse_args(&args(&["--help"]));
375        assert!(result.is_err());
376        assert_eq!(result.unwrap_err().exit_code, 0);
377    }
378
379    #[test]
380    fn test_default_config_when_no_args() {
381        let result = parse_args(&[]);
382        assert!(result.is_ok());
383        let config = result.unwrap();
384        assert_eq!(config.base_url, "http://127.0.0.1:11434");
385        assert_eq!(config.default_model, "glm-5.2:cloud");
386        assert!(config.data_dir.is_none());
387        assert!(!config.ephemeral);
388    }
389
390    #[test]
391    fn test_ephemeral_alone_accepted() {
392        let result = parse_args(&args(&["--ephemeral"]));
393        assert!(result.is_ok());
394        assert!(result.unwrap().ephemeral);
395    }
396
397    #[test]
398    fn test_integrity_key_path_recorded() {
399        let result = parse_args(&args(&[
400            "--data-dir",
401            "/tmp/test",
402            "--integrity-key",
403            "/tmp/my-key",
404        ]));
405        assert!(result.is_ok());
406        let config = result.unwrap();
407        assert_eq!(
408            config.integrity_key_path,
409            Some(PathBuf::from("/tmp/my-key"))
410        );
411    }
412
413    #[test]
414    fn test_max_graphs_records_configured_value() {
415        let config = parse_args(&args(&["--max-graphs", "256"])).expect("valid limit");
416        assert_eq!(config.max_graphs, 256);
417    }
418
419    #[test]
420    fn test_zero_max_graphs_is_rejected() {
421        let error = parse_args(&args(&["--max-graphs", "0"])).expect_err("zero is invalid");
422        assert!(error.message.contains("between 1 and"));
423    }
424
425    #[test]
426    fn test_excessive_max_graphs_is_rejected() {
427        let error = parse_args(&args(&["--max-graphs", "1025"])).expect_err("hard cap");
428        assert!(error.message.contains("between 1 and"));
429    }
430}