mcp_sync/
canon.rs

1//! # Canonical Configuration
2//!
3//! Parsing and representation of the canonical `mcp.yaml` configuration file.
4
5use anyhow::{Context, Result};
6use serde::Deserialize;
7use std::{
8    collections::BTreeMap,
9    fs,
10    path::Path,
11};
12
13/// The top-level canonical configuration structure.
14///
15/// # Example YAML
16/// ```yaml
17/// version: 1
18/// servers:
19///   my-server:
20///     command: npx
21///     args: ["-y", "@modelcontextprotocol/server"]
22/// ```
23#[derive(Debug, Deserialize, Clone)]
24pub struct Canon {
25    /// Configuration file version (currently unused, for future compatibility).
26    #[allow(dead_code)]
27    pub version: Option<u32>,
28
29    /// Map of server name to server configuration.
30    pub servers: BTreeMap<String, CanonServer>,
31
32    /// Plugin configurations (optional).
33    #[serde(default)]
34    pub plugins: Vec<PluginConfig>,
35}
36
37/// Configuration for a single MCP server.
38///
39/// Supports two kinds of servers:
40/// - **stdio**: Local process with `command`, `args`, and optional `env`/`cwd`
41/// - **http**: Remote server with `url` and optional `headers`
42#[derive(Debug, Deserialize, Clone)]
43pub struct CanonServer {
44    /// Explicit kind: "stdio" or "http". Auto-detected if not specified.
45    pub kind: Option<String>,
46
47    // ─── stdio fields ───
48    /// Command to execute (for stdio servers).
49    pub command: Option<String>,
50    /// Arguments to pass to the command.
51    pub args: Option<Vec<String>>,
52    /// Environment variables to set.
53    pub env: Option<BTreeMap<String, String>>,
54    /// Working directory for the process.
55    pub cwd: Option<String>,
56
57    // ─── http fields ───
58    /// URL for HTTP servers.
59    pub url: Option<String>,
60    /// HTTP headers to include in requests.
61    pub headers: Option<BTreeMap<String, String>>,
62    /// Environment variable containing the bearer token.
63    pub bearer_token_env_var: Option<String>,
64
65    // ─── common fields ───
66    /// Whether the server is enabled (default: true).
67    pub enabled: Option<bool>,
68}
69
70impl CanonServer {
71    /// Returns the server kind, auto-detecting based on fields if not explicit.
72    pub fn kind(&self) -> &'static str {
73        if let Some(k) = &self.kind {
74            let k = k.to_lowercase();
75            if k == "http" {
76                return "http";
77            }
78            if k == "stdio" {
79                return "stdio";
80            }
81        }
82        // Auto-detect: if url is present, it's http; otherwise stdio
83        if self.url.is_some() {
84            "http"
85        } else {
86            "stdio"
87        }
88    }
89
90    /// Returns whether the server is enabled (default: true).
91    pub fn enabled(&self) -> bool {
92        self.enabled.unwrap_or(true)
93    }
94}
95
96/// Plugin configuration from the canonical file.
97#[derive(Debug, Deserialize, Clone)]
98pub struct PluginConfig {
99    /// Plugin name (for built-in plugins).
100    pub name: Option<String>,
101    /// Path to dynamic library (for external plugins).
102    pub path: Option<String>,
103    /// Plugin-specific configuration.
104    #[serde(default)]
105    pub config: serde_json::Value,
106}
107
108/// Reads and parses the canonical configuration file.
109///
110/// # Arguments
111/// * `path` - Path to the `mcp.yaml` file
112///
113/// # Returns
114/// The parsed `Canon` structure, or an error if reading/parsing fails.
115pub fn read_canon(path: &Path) -> Result<Canon> {
116    let data = fs::read_to_string(path).with_context(|| format!("read {:?}", path))?;
117    let canon: Canon = serde_yaml::from_str(&data).context("parse YAML")?;
118    Ok(canon)
119}
120
121/// Reads and parses the canonical configuration from a URL.
122///
123/// # Arguments
124/// * `url` - URL to fetch the `mcp.yaml` file from (http:// or https://)
125///
126/// # Returns
127/// The parsed `Canon` structure, or an error if fetching/parsing fails.
128pub fn read_canon_from_url(url: &str) -> Result<Canon> {
129    use anyhow::anyhow;
130    
131    let response = reqwest::blocking::get(url)
132        .with_context(|| format!("fetch {}", url))?;
133    
134    if !response.status().is_success() {
135        return Err(anyhow!("HTTP {}: {}", response.status(), url));
136    }
137    
138    let data = response.text().context("read response body")?;
139    let canon: Canon = serde_yaml::from_str(&data).context("parse YAML")?;
140    Ok(canon)
141}
142
143/// Reads canon from a path or URL (auto-detects based on prefix).
144pub fn read_canon_auto(path_or_url: &str) -> Result<Canon> {
145    if path_or_url.starts_with("http://") || path_or_url.starts_with("https://") {
146        read_canon_from_url(path_or_url)
147    } else {
148        read_canon(Path::new(path_or_url))
149    }
150}
151
152/// Returns a set of all server names in the canonical config.
153pub fn canon_names(canon: &Canon) -> std::collections::BTreeSet<String> {
154    canon.servers.keys().cloned().collect()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::fs;
161    use tempfile::TempDir;
162
163    // ─────────────────────────── CanonServer::kind tests ───────────────────────────
164
165    #[test]
166    fn test_canon_server_kind_explicit_http() {
167        let server = CanonServer {
168            kind: Some("http".to_string()),
169            command: None,
170            args: None,
171            env: None,
172            cwd: None,
173            url: None,
174            headers: None,
175            bearer_token_env_var: None,
176            enabled: None,
177        };
178        assert_eq!(server.kind(), "http");
179    }
180
181    #[test]
182    fn test_canon_server_kind_explicit_http_uppercase() {
183        let server = CanonServer {
184            kind: Some("HTTP".to_string()),
185            command: None,
186            args: None,
187            env: None,
188            cwd: None,
189            url: None,
190            headers: None,
191            bearer_token_env_var: None,
192            enabled: None,
193        };
194        assert_eq!(server.kind(), "http");
195    }
196
197    #[test]
198    fn test_canon_server_kind_explicit_stdio() {
199        let server = CanonServer {
200            kind: Some("stdio".to_string()),
201            command: Some("echo".to_string()),
202            args: None,
203            env: None,
204            cwd: None,
205            url: None,
206            headers: None,
207            bearer_token_env_var: None,
208            enabled: None,
209        };
210        assert_eq!(server.kind(), "stdio");
211    }
212
213    #[test]
214    fn test_canon_server_kind_auto_detect_http() {
215        let server = CanonServer {
216            kind: None,
217            command: None,
218            args: None,
219            env: None,
220            cwd: None,
221            url: Some("https://example.com".to_string()),
222            headers: None,
223            bearer_token_env_var: None,
224            enabled: None,
225        };
226        assert_eq!(server.kind(), "http");
227    }
228
229    #[test]
230    fn test_canon_server_kind_auto_detect_stdio() {
231        let server = CanonServer {
232            kind: None,
233            command: Some("npx".to_string()),
234            args: Some(vec!["-y".to_string(), "some-package".to_string()]),
235            env: None,
236            cwd: None,
237            url: None,
238            headers: None,
239            bearer_token_env_var: None,
240            enabled: None,
241        };
242        assert_eq!(server.kind(), "stdio");
243    }
244
245    #[test]
246    fn test_canon_server_kind_invalid_defaults_to_stdio() {
247        let server = CanonServer {
248            kind: Some("unknown".to_string()),
249            command: None,
250            args: None,
251            env: None,
252            cwd: None,
253            url: None,
254            headers: None,
255            bearer_token_env_var: None,
256            enabled: None,
257        };
258        assert_eq!(server.kind(), "stdio");
259    }
260
261    // ─────────────────────────── CanonServer::enabled tests ───────────────────────────
262
263    #[test]
264    fn test_canon_server_enabled_default_true() {
265        let server = CanonServer {
266            kind: None,
267            command: Some("echo".to_string()),
268            args: None,
269            env: None,
270            cwd: None,
271            url: None,
272            headers: None,
273            bearer_token_env_var: None,
274            enabled: None,
275        };
276        assert!(server.enabled());
277    }
278
279    #[test]
280    fn test_canon_server_enabled_explicit_true() {
281        let server = CanonServer {
282            kind: None,
283            command: Some("echo".to_string()),
284            args: None,
285            env: None,
286            cwd: None,
287            url: None,
288            headers: None,
289            bearer_token_env_var: None,
290            enabled: Some(true),
291        };
292        assert!(server.enabled());
293    }
294
295    #[test]
296    fn test_canon_server_enabled_explicit_false() {
297        let server = CanonServer {
298            kind: None,
299            command: Some("echo".to_string()),
300            args: None,
301            env: None,
302            cwd: None,
303            url: None,
304            headers: None,
305            bearer_token_env_var: None,
306            enabled: Some(false),
307        };
308        assert!(!server.enabled());
309    }
310
311    // ─────────────────────────── read_canon tests ───────────────────────────
312
313    #[test]
314    fn test_read_canon_valid_yaml() {
315        let temp = TempDir::new().unwrap();
316        let yaml_path = temp.path().join("mcp.yaml");
317        fs::write(&yaml_path, r#"
318version: 1
319servers:
320  test-server:
321    command: echo
322    args:
323      - hello
324      - world
325"#).unwrap();
326
327        let canon = read_canon(&yaml_path).unwrap();
328        
329        assert_eq!(canon.version, Some(1));
330        assert_eq!(canon.servers.len(), 1);
331        assert!(canon.servers.contains_key("test-server"));
332        
333        let server = &canon.servers["test-server"];
334        assert_eq!(server.command, Some("echo".to_string()));
335        assert_eq!(server.args, Some(vec!["hello".to_string(), "world".to_string()]));
336    }
337
338    #[test]
339    fn test_read_canon_http_server() {
340        let temp = TempDir::new().unwrap();
341        let yaml_path = temp.path().join("mcp.yaml");
342        fs::write(&yaml_path, r#"
343version: 1
344servers:
345  remote:
346    url: https://api.example.com
347    headers:
348      Authorization: Bearer token123
349"#).unwrap();
350
351        let canon = read_canon(&yaml_path).unwrap();
352        let server = &canon.servers["remote"];
353        
354        assert_eq!(server.kind(), "http");
355        assert_eq!(server.url, Some("https://api.example.com".to_string()));
356        assert!(server.headers.as_ref().unwrap().contains_key("Authorization"));
357    }
358
359    #[test]
360    fn test_read_canon_with_env() {
361        let temp = TempDir::new().unwrap();
362        let yaml_path = temp.path().join("mcp.yaml");
363        fs::write(&yaml_path, r#"
364version: 1
365servers:
366  with-env:
367    command: my-cli
368    env:
369      API_KEY: secret123
370      DEBUG: "true"
371"#).unwrap();
372
373        let canon = read_canon(&yaml_path).unwrap();
374        let server = &canon.servers["with-env"];
375        let env = server.env.as_ref().unwrap();
376        
377        assert_eq!(env.get("API_KEY"), Some(&"secret123".to_string()));
378        assert_eq!(env.get("DEBUG"), Some(&"true".to_string()));
379    }
380
381    #[test]
382    fn test_read_canon_with_plugins() {
383        let temp = TempDir::new().unwrap();
384        let yaml_path = temp.path().join("mcp.yaml");
385        fs::write(&yaml_path, r#"
386version: 1
387plugins:
388  - name: env-expander
389    config:
390      prefix: "${"
391      suffix: "}"
392servers:
393  test:
394    command: echo
395"#).unwrap();
396
397        let canon = read_canon(&yaml_path).unwrap();
398        
399        assert_eq!(canon.plugins.len(), 1);
400        assert_eq!(canon.plugins[0].name, Some("env-expander".to_string()));
401    }
402
403    #[test]
404    fn test_read_canon_multiple_servers() {
405        let temp = TempDir::new().unwrap();
406        let yaml_path = temp.path().join("mcp.yaml");
407        fs::write(&yaml_path, r#"
408version: 1
409servers:
410  server1:
411    command: cmd1
412  server2:
413    command: cmd2
414  server3:
415    url: https://example.com
416"#).unwrap();
417
418        let canon = read_canon(&yaml_path).unwrap();
419        
420        assert_eq!(canon.servers.len(), 3);
421        assert!(canon.servers.contains_key("server1"));
422        assert!(canon.servers.contains_key("server2"));
423        assert!(canon.servers.contains_key("server3"));
424    }
425
426    #[test]
427    fn test_read_canon_nonexistent_file_error() {
428        let temp = TempDir::new().unwrap();
429        let yaml_path = temp.path().join("nonexistent.yaml");
430        
431        let result = read_canon(&yaml_path);
432        
433        assert!(result.is_err());
434    }
435
436    #[test]
437    fn test_read_canon_invalid_yaml_error() {
438        let temp = TempDir::new().unwrap();
439        let yaml_path = temp.path().join("invalid.yaml");
440        fs::write(&yaml_path, "invalid: yaml: content: [").unwrap();
441        
442        let result = read_canon(&yaml_path);
443        
444        assert!(result.is_err());
445    }
446
447    #[test]
448    fn test_read_canon_empty_servers() {
449        let temp = TempDir::new().unwrap();
450        let yaml_path = temp.path().join("mcp.yaml");
451        fs::write(&yaml_path, r#"
452version: 1
453servers: {}
454"#).unwrap();
455
456        let canon = read_canon(&yaml_path).unwrap();
457        
458        assert!(canon.servers.is_empty());
459    }
460
461    // ─────────────────────────── canon_names tests ───────────────────────────
462
463    #[test]
464    fn test_canon_names_empty() {
465        let canon = Canon {
466            version: Some(1),
467            servers: BTreeMap::new(),
468            plugins: vec![],
469        };
470        
471        let names = canon_names(&canon);
472        
473        assert!(names.is_empty());
474    }
475
476    #[test]
477    fn test_canon_names_multiple() {
478        let mut servers = BTreeMap::new();
479        servers.insert("alpha".to_string(), CanonServer {
480            kind: None, command: Some("a".to_string()), args: None, env: None,
481            cwd: None, url: None, headers: None, bearer_token_env_var: None, enabled: None,
482        });
483        servers.insert("beta".to_string(), CanonServer {
484            kind: None, command: Some("b".to_string()), args: None, env: None,
485            cwd: None, url: None, headers: None, bearer_token_env_var: None, enabled: None,
486        });
487        servers.insert("gamma".to_string(), CanonServer {
488            kind: None, command: Some("c".to_string()), args: None, env: None,
489            cwd: None, url: None, headers: None, bearer_token_env_var: None, enabled: None,
490        });
491        
492        let canon = Canon { version: Some(1), servers, plugins: vec![] };
493        let names = canon_names(&canon);
494        
495        assert_eq!(names.len(), 3);
496        assert!(names.contains("alpha"));
497        assert!(names.contains("beta"));
498        assert!(names.contains("gamma"));
499    }
500}
501