1use anyhow::{Context, Result};
6use serde::Deserialize;
7use std::{
8 collections::BTreeMap,
9 fs,
10 path::Path,
11};
12
13#[derive(Debug, Deserialize, Clone)]
24pub struct Canon {
25 #[allow(dead_code)]
27 pub version: Option<u32>,
28
29 pub servers: BTreeMap<String, CanonServer>,
31
32 #[serde(default)]
34 pub plugins: Vec<PluginConfig>,
35}
36
37#[derive(Debug, Deserialize, Clone)]
43pub struct CanonServer {
44 pub kind: Option<String>,
46
47 pub command: Option<String>,
50 pub args: Option<Vec<String>>,
52 pub env: Option<BTreeMap<String, String>>,
54 pub cwd: Option<String>,
56
57 pub url: Option<String>,
60 pub headers: Option<BTreeMap<String, String>>,
62 pub bearer_token_env_var: Option<String>,
64
65 pub enabled: Option<bool>,
68}
69
70impl CanonServer {
71 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 if self.url.is_some() {
84 "http"
85 } else {
86 "stdio"
87 }
88 }
89
90 pub fn enabled(&self) -> bool {
92 self.enabled.unwrap_or(true)
93 }
94}
95
96#[derive(Debug, Deserialize, Clone)]
98pub struct PluginConfig {
99 pub name: Option<String>,
101 pub path: Option<String>,
103 #[serde(default)]
105 pub config: serde_json::Value,
106}
107
108pub 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
121pub 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
143pub 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
152pub 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 #[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 #[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 #[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 #[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