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