mcp_sync/commands/
validate.rs

1//! `mcp-sync validate` command - validate mcp.yaml syntax and schema.
2
3use crate::canon::{read_canon_auto, Canon};
4use anyhow::Result;
5use tracing::{error, info, warn};
6
7/// Validation error with location info.
8#[derive(Debug)]
9pub struct ValidationError {
10    pub server: Option<String>,
11    pub field: String,
12    pub message: String,
13}
14
15impl std::fmt::Display for ValidationError {
16    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
17        if let Some(server) = &self.server {
18            write!(f, "servers.{}.{}: {}", server, self.field, self.message)
19        } else {
20            write!(f, "{}: {}", self.field, self.message)
21        }
22    }
23}
24
25/// Validates a Canon configuration.
26pub fn validate_canon(canon: &Canon) -> Vec<ValidationError> {
27    let mut errors = Vec::new();
28    
29    for (name, server) in &canon.servers {
30        // Check kind
31        if let Some(kind) = &server.kind {
32            let k = kind.to_lowercase();
33            if k != "http" && k != "stdio" {
34                errors.push(ValidationError {
35                    server: Some(name.clone()),
36                    field: "kind".to_string(),
37                    message: format!("invalid kind '{}', expected 'http' or 'stdio'", kind),
38                });
39            }
40        }
41        
42        // Check required fields based on kind
43        let is_http = server.kind() == "http";
44        
45        if is_http {
46            if server.url.is_none() {
47                errors.push(ValidationError {
48                    server: Some(name.clone()),
49                    field: "url".to_string(),
50                    message: "http server requires 'url' field".to_string(),
51                });
52            }
53            if server.command.is_some() {
54                errors.push(ValidationError {
55                    server: Some(name.clone()),
56                    field: "command".to_string(),
57                    message: "http server should not have 'command' field".to_string(),
58                });
59            }
60        } else if server.command.is_none() {
61            errors.push(ValidationError {
62                server: Some(name.clone()),
63                field: "command".to_string(),
64                message: "stdio server requires 'command' field".to_string(),
65            });
66        } else if server.url.is_some() {
67            errors.push(ValidationError {
68                server: Some(name.clone()),
69                field: "url".to_string(),
70                message: "stdio server should not have 'url' field".to_string(),
71            });
72        }
73        
74        // Check URL format for http
75        if let Some(url) = &server.url
76            && !url.starts_with("http://") && !url.starts_with("https://") {
77            errors.push(ValidationError {
78                server: Some(name.clone()),
79                field: "url".to_string(),
80                message: "url must start with http:// or https://".to_string(),
81            });
82        }
83    }
84    
85    errors
86}
87
88/// Runs the validate command.
89pub fn run(canon_path: &str) -> Result<bool> {
90    info!("Validating {}", canon_path);
91    
92    // Parse YAML
93    let canon = match read_canon_auto(canon_path) {
94        Ok(c) => c,
95        Err(e) => {
96            error!("Failed to parse: {:#}", e);
97            return Ok(false);
98        }
99    };
100    
101    info!("Parsed {} servers, {} plugins", 
102        canon.servers.len(), 
103        canon.plugins.len()
104    );
105    
106    // Validate schema
107    let errors = validate_canon(&canon);
108    
109    if errors.is_empty() {
110        info!("✅ Validation passed");
111        Ok(true)
112    } else {
113        for err in &errors {
114            error!("❌ {}", err);
115        }
116        warn!("{} validation error(s) found", errors.len());
117        Ok(false)
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::canon::{Canon, CanonServer};
125    use std::collections::BTreeMap;
126    
127    #[test]
128    fn test_validate_valid_stdio_server() {
129        let mut servers = BTreeMap::new();
130        servers.insert("test".to_string(), CanonServer {
131            kind: None,
132            command: Some("echo".to_string()),
133            args: None,
134            env: None,
135            cwd: None,
136            url: None,
137            headers: None,
138            bearer_token_env_var: None,
139            enabled: None,
140        });
141        let canon = Canon { version: Some(1), servers, plugins: vec![] };
142        
143        let errors = validate_canon(&canon);
144        assert!(errors.is_empty());
145    }
146    
147    #[test]
148    fn test_validate_valid_http_server() {
149        let mut servers = BTreeMap::new();
150        servers.insert("api".to_string(), CanonServer {
151            kind: Some("http".to_string()),
152            command: None,
153            args: None,
154            env: None,
155            cwd: None,
156            url: Some("https://example.com".to_string()),
157            headers: None,
158            bearer_token_env_var: None,
159            enabled: None,
160        });
161        let canon = Canon { version: Some(1), servers, plugins: vec![] };
162        
163        let errors = validate_canon(&canon);
164        assert!(errors.is_empty());
165    }
166    
167    #[test]
168    fn test_validate_stdio_missing_command() {
169        let mut servers = BTreeMap::new();
170        servers.insert("test".to_string(), CanonServer {
171            kind: None,
172            command: None,
173            args: None,
174            env: None,
175            cwd: None,
176            url: None,
177            headers: None,
178            bearer_token_env_var: None,
179            enabled: None,
180        });
181        let canon = Canon { version: Some(1), servers, plugins: vec![] };
182        
183        let errors = validate_canon(&canon);
184        assert_eq!(errors.len(), 1);
185        assert!(errors[0].message.contains("command"));
186    }
187    
188    #[test]
189    fn test_validate_http_missing_url() {
190        let mut servers = BTreeMap::new();
191        servers.insert("api".to_string(), CanonServer {
192            kind: Some("http".to_string()),
193            command: None,
194            args: None,
195            env: None,
196            cwd: None,
197            url: None,
198            headers: None,
199            bearer_token_env_var: None,
200            enabled: None,
201        });
202        let canon = Canon { version: Some(1), servers, plugins: vec![] };
203        
204        let errors = validate_canon(&canon);
205        assert_eq!(errors.len(), 1);
206        assert!(errors[0].message.contains("url"));
207    }
208    
209    #[test]
210    fn test_validate_invalid_url_scheme() {
211        let mut servers = BTreeMap::new();
212        servers.insert("api".to_string(), CanonServer {
213            kind: Some("http".to_string()),
214            command: None,
215            args: None,
216            env: None,
217            cwd: None,
218            url: Some("ftp://example.com".to_string()),
219            headers: None,
220            bearer_token_env_var: None,
221            enabled: None,
222        });
223        let canon = Canon { version: Some(1), servers, plugins: vec![] };
224        
225        let errors = validate_canon(&canon);
226        assert!(!errors.is_empty());
227    }
228}