1use std::collections::BTreeMap;
40use std::path::{Path, PathBuf};
41
42use serde::Deserialize;
43
44#[derive(Debug, Default, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct Config {
49 #[serde(default)]
50 pub servers: BTreeMap<String, Profile>,
51 #[serde(default)]
54 pub oauth: BTreeMap<String, OAuthProfile>,
55 #[serde(default)]
57 pub aliases: BTreeMap<String, String>,
58 #[serde(default)]
60 pub repl: Repl,
61}
62
63#[derive(Debug, Default, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct Repl {
67 pub history_capacity: Option<usize>,
70 pub request_timeout: Option<u64>,
73 pub completion_timeout_ms: Option<u64>,
78}
79
80#[derive(Debug, Default, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct Profile {
84 pub transport: Option<Transport>,
86 pub url: Option<String>,
88 pub bearer: Option<String>,
90 pub bearer_env: Option<String>,
92 pub oauth: Option<String>,
94 #[serde(default)]
96 pub headers: BTreeMap<String, String>,
97 #[serde(default)]
99 pub command: Vec<String>,
100 #[serde(default)]
103 pub aliases: BTreeMap<String, String>,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct OAuthProfile {
110 pub url: String,
112 #[serde(default)]
114 pub scopes: Vec<String>,
115 pub client_id_metadata_document: Option<String>,
117 pub authorization_server: Option<String>,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum Transport {
127 Http,
128 Stdio,
129}
130
131#[derive(Debug, PartialEq, Eq)]
134pub enum Connection {
135 Http {
136 url: String,
137 bearer: Option<String>,
138 headers: Vec<(String, String)>,
139 oauth: Option<String>,
140 },
141 Stdio {
142 command: Vec<String>,
143 env: BTreeMap<String, String>,
144 cwd: Option<PathBuf>,
145 },
146}
147
148impl Config {
149 pub fn parse(source: &str) -> Result<Self, String> {
151 toml::from_str(source).map_err(|e| e.to_string())
152 }
153
154 pub fn load(path: &Path, explicit: bool) -> Result<Self, String> {
158 match std::fs::read_to_string(path) {
159 Ok(source) => {
160 crate::secure_file::restrict_existing(path);
165 Self::parse(&source).map_err(|e| format!("{}: {e}", path.display()))
166 }
167 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !explicit => Ok(Self::default()),
168 Err(e) => Err(format!("{}: {e}", path.display())),
169 }
170 }
171
172 pub fn profile(&self, name: &str) -> Result<&Profile, String> {
175 self.servers.get(name).ok_or_else(|| {
176 if self.servers.is_empty() {
177 format!("no server profile named {name:?}: no profiles are configured")
178 } else {
179 format!(
180 "no server profile named {name:?}: known profiles are {}",
181 self.names().join(", ")
182 )
183 }
184 })
185 }
186
187 pub fn names(&self) -> Vec<&str> {
189 self.servers.keys().map(String::as_str).collect()
190 }
191
192 pub fn resolve_profile_with(
195 &self,
196 name: &str,
197 lookup: impl Fn(&str) -> Option<String>,
198 ) -> Result<Connection, String> {
199 let profile = self.profile(name)?;
200 let oauth_url = profile
201 .oauth
202 .as_deref()
203 .map(|oauth| {
204 self.oauth
205 .get(oauth)
206 .map(|metadata| metadata.url.as_str())
207 .ok_or_else(|| {
208 format!("server profile references unknown OAuth profile {oauth:?}")
209 })
210 })
211 .transpose()?;
212 profile.resolve_with_oauth_url(lookup, oauth_url)
213 }
214}
215
216impl Profile {
217 pub fn transport(&self) -> Result<Transport, String> {
220 match (
221 self.transport,
222 self.url.is_some() || self.oauth.is_some(),
223 !self.command.is_empty(),
224 ) {
225 (Some(t), _, _) => Ok(t),
226 (None, true, false) => Ok(Transport::Http),
227 (None, false, true) => Ok(Transport::Stdio),
228 (None, true, true) => Err(
229 "profile sets both `url` and `command`: add `transport = \"http\"` or \
230 `transport = \"stdio\"` to say which one applies"
231 .to_string(),
232 ),
233 (None, false, false) => {
234 Err("profile has neither `url` nor `command`, so it cannot connect".to_string())
235 }
236 }
237 }
238
239 pub fn bearer_token_with(
243 &self,
244 lookup: impl Fn(&str) -> Option<String>,
245 ) -> Result<Option<String>, String> {
246 if let Some(var) = &self.bearer_env {
247 return lookup(var).map(Some).ok_or_else(|| {
248 format!(
249 "profile sets `bearer_env = {var:?}` but that environment variable is unset"
250 )
251 });
252 }
253 Ok(self.bearer.clone())
254 }
255
256 #[cfg(test)]
259 pub fn resolve_with(
260 &self,
261 lookup: impl Fn(&str) -> Option<String>,
262 ) -> Result<Connection, String> {
263 self.resolve_with_oauth_url(lookup, None)
264 }
265
266 fn resolve_with_oauth_url(
267 &self,
268 lookup: impl Fn(&str) -> Option<String>,
269 oauth_url: Option<&str>,
270 ) -> Result<Connection, String> {
271 match self.transport()? {
272 Transport::Http => {
273 if self.oauth.is_some()
274 && (self.bearer.is_some()
275 || self.bearer_env.is_some()
276 || self
277 .headers
278 .keys()
279 .any(|name| name.eq_ignore_ascii_case("authorization")))
280 {
281 return Err(
282 "HTTP profile cannot combine `oauth` with `bearer`, `bearer_env`, or an \
283 Authorization header"
284 .to_string(),
285 );
286 }
287 let url = self
288 .url
289 .clone()
290 .or_else(|| oauth_url.map(str::to_string))
291 .ok_or("profile has `transport = \"http\"` but no `url`")?;
292 Ok(Connection::Http {
293 url,
294 bearer: self.bearer_token_with(lookup)?,
295 headers: self
296 .headers
297 .iter()
298 .map(|(k, v)| (k.clone(), v.clone()))
299 .collect(),
300 oauth: self.oauth.clone(),
301 })
302 }
303 Transport::Stdio => {
304 if self.command.is_empty() {
305 return Err("profile has `transport = \"stdio\"` but no `command`".to_string());
306 }
307 Ok(Connection::Stdio {
308 command: self.command.clone(),
309 env: BTreeMap::new(),
310 cwd: None,
311 })
312 }
313 }
314 }
315
316 pub fn summary(&self) -> String {
318 match self.transport() {
319 Ok(Transport::Http) => format!(
320 "http {}",
321 self.url
322 .as_deref()
323 .or(self.oauth.as_deref())
324 .unwrap_or("(no url)")
325 ),
326 Ok(Transport::Stdio) => format!("stdio {}", self.command.join(" ")),
327 Err(e) => format!("(invalid: {e})"),
328 }
329 }
330}
331
332pub fn config_path(explicit: Option<&str>) -> Option<(PathBuf, bool)> {
337 config_path_with(explicit, &crate::directories::Directories::current())
338}
339
340fn config_path_with(
341 explicit: Option<&str>,
342 directories: &crate::directories::Directories,
343) -> Option<(PathBuf, bool)> {
344 if let Some(p) = explicit {
345 return Some((PathBuf::from(p), true));
346 }
347 Some((directories.config_file()?, false))
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353 use crate::directories::{Directories, Platform};
354 use std::ffi::OsString;
355
356 #[test]
357 fn windows_directories_drive_the_default_config_path() {
358 let directories = Directories::from_lookup(Platform::Windows, |name| {
359 (name == "APPDATA").then(|| OsString::from(r"C:\Users\Ada\AppData\Roaming"))
360 });
361 assert_eq!(
362 config_path_with(None, &directories),
363 Some((
364 PathBuf::from(r"C:\Users\Ada\AppData\Roaming")
365 .join("mcp-repl")
366 .join("config.toml"),
367 false
368 ))
369 );
370 assert_eq!(
371 config_path_with(Some("portable.toml"), &directories),
372 Some((PathBuf::from("portable.toml"), true))
373 );
374 }
375
376 const SAMPLE: &str = r#"
377[servers.cratesio]
378transport = "http"
379url = "https://cratesio-mcp.fly.dev/"
380bearer_env = "CRATESIO_TOKEN"
381headers = { "X-Api-Key" = "abc" }
382
383[servers.local]
384transport = "stdio"
385command = ["cargo", "run", "--example", "getting_started"]
386"#;
387
388 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
389 let map: BTreeMap<String, String> = pairs
390 .iter()
391 .map(|(k, v)| (k.to_string(), v.to_string()))
392 .collect();
393 move |k: &str| map.get(k).cloned()
394 }
395
396 #[test]
397 fn the_repl_table_is_optional_and_defaults_to_nothing_set() {
398 let config: Config = toml::from_str(SAMPLE).expect("parses");
401 assert_eq!(config.repl.history_capacity, None);
402 assert_eq!(config.repl.request_timeout, None);
403 assert_eq!(config.repl.completion_timeout_ms, None);
404 }
405
406 #[test]
407 fn the_repl_table_parses_its_tunables() {
408 let config: Config = toml::from_str(
409 r#"
410[repl]
411history_capacity = 50
412request_timeout = 7
413completion_timeout_ms = 250
414"#,
415 )
416 .expect("parses");
417 assert_eq!(config.repl.history_capacity, Some(50));
418 assert_eq!(config.repl.request_timeout, Some(7));
419 assert_eq!(config.repl.completion_timeout_ms, Some(250));
420 }
421
422 #[test]
425 fn a_misspelled_repl_key_is_refused_and_names_the_alternatives() {
426 let error = toml::from_str::<Config>("[repl]\nhistory_capacty = 50\n")
427 .expect_err("a typo is an error");
428 let message = error.to_string();
429 assert!(message.contains("history_capacty"), "{message}");
430 assert!(message.contains("history_capacity"), "{message}");
431 }
432
433 #[test]
436 fn zero_is_a_setting_rather_than_an_unset_key() {
437 let config: Config =
438 toml::from_str("[repl]\nhistory_capacity = 0\nrequest_timeout = 0\n").expect("parses");
439 assert_eq!(config.repl.history_capacity, Some(0));
440 assert_eq!(config.repl.request_timeout, Some(0));
441 }
442
443 #[test]
444 fn parses_named_profiles() {
445 let config = Config::parse(SAMPLE).unwrap();
446 assert_eq!(config.names(), vec!["cratesio", "local"]);
447 }
448
449 #[test]
450 fn http_profile_resolves_transport_and_auth() {
451 let config = Config::parse(SAMPLE).unwrap();
452 let resolved = config
453 .profile("cratesio")
454 .unwrap()
455 .resolve_with(env(&[("CRATESIO_TOKEN", "secret")]))
456 .unwrap();
457 assert_eq!(
458 resolved,
459 Connection::Http {
460 url: "https://cratesio-mcp.fly.dev/".to_string(),
461 bearer: Some("secret".to_string()),
462 headers: vec![("X-Api-Key".to_string(), "abc".to_string())],
463 oauth: None,
464 }
465 );
466 }
467
468 #[test]
469 fn oauth_metadata_and_server_selection_are_non_secret() {
470 let config = Config::parse(
471 r#"
472[oauth.work]
473url = "https://mcp.example/mcp"
474scopes = ["openid", "offline_access"]
475client_id_metadata_document = "https://client.example/metadata.json"
476authorization_server = "https://auth.example"
477
478[servers.work]
479oauth = "work"
480headers = { "X-Tenant" = "acme" }
481"#,
482 )
483 .unwrap();
484
485 assert_eq!(config.oauth["work"].scopes, ["openid", "offline_access"]);
486 assert_eq!(
487 config.resolve_profile_with("work", env(&[])).unwrap(),
488 Connection::Http {
489 url: "https://mcp.example/mcp".to_string(),
490 bearer: None,
491 headers: vec![("X-Tenant".to_string(), "acme".to_string())],
492 oauth: Some("work".to_string()),
493 }
494 );
495 }
496
497 #[test]
498 fn unknown_oauth_reference_is_an_actionable_error() {
499 let config = Config::parse("[servers.work]\noauth = \"missing\"\n").unwrap();
500 let error = config.resolve_profile_with("work", env(&[])).unwrap_err();
501 assert!(
502 error.contains("unknown OAuth profile \"missing\""),
503 "{error}"
504 );
505 }
506
507 #[test]
508 fn oauth_server_profile_rejects_ambiguous_static_auth() {
509 for auth in [
510 "bearer = \"secret\"",
511 "bearer_env = \"TOKEN\"",
512 "headers = { Authorization = \"Bearer secret\" }",
513 ] {
514 let source = format!(
515 "[servers.work]\nurl = \"https://mcp.example/mcp\"\noauth = \"work\"\n{auth}\n"
516 );
517 let error = Config::parse(&source)
518 .unwrap()
519 .profile("work")
520 .unwrap()
521 .resolve_with(env(&[("TOKEN", "secret")]))
522 .unwrap_err();
523 assert!(error.contains("cannot combine `oauth`"), "{error}");
524 }
525 }
526
527 #[test]
528 fn stdio_profile_resolves_command() {
529 let config = Config::parse(SAMPLE).unwrap();
530 let resolved = config
531 .profile("local")
532 .unwrap()
533 .resolve_with(env(&[]))
534 .unwrap();
535 assert_eq!(
536 resolved,
537 Connection::Stdio {
538 command: vec![
539 "cargo".to_string(),
540 "run".to_string(),
541 "--example".to_string(),
542 "getting_started".to_string(),
543 ],
544 env: BTreeMap::new(),
545 cwd: None,
546 }
547 );
548 }
549
550 #[test]
551 fn unknown_profile_lists_known_names() {
552 let config = Config::parse(SAMPLE).unwrap();
553 let err = config.profile("nope").unwrap_err();
554 assert!(err.contains("nope"), "{err}");
555 assert!(err.contains("cratesio, local"), "{err}");
556 }
557
558 #[test]
559 fn unknown_profile_with_empty_config_says_so() {
560 let err = Config::default().profile("nope").unwrap_err();
561 assert!(err.contains("no profiles are configured"), "{err}");
562 }
563
564 #[test]
565 fn unset_bearer_env_is_an_error() {
566 let config = Config::parse(SAMPLE).unwrap();
567 let err = config
568 .profile("cratesio")
569 .unwrap()
570 .resolve_with(env(&[]))
571 .unwrap_err();
572 assert!(err.contains("CRATESIO_TOKEN"), "{err}");
573 }
574
575 #[test]
576 fn inline_bearer_is_used_when_no_env_indirection() {
577 let profile: Profile = toml::from_str(
578 r#"
579 url = "https://example/mcp"
580 bearer = "literal"
581 "#,
582 )
583 .unwrap();
584 assert_eq!(
585 profile.bearer_token_with(env(&[])).unwrap(),
586 Some("literal".to_string())
587 );
588 }
589
590 #[test]
591 fn transport_is_inferred_from_the_fields() {
592 let http: Profile = toml::from_str(r#"url = "https://example/mcp""#).unwrap();
593 assert_eq!(http.transport().unwrap(), Transport::Http);
594 let stdio: Profile = toml::from_str(r#"command = ["server"]"#).unwrap();
595 assert_eq!(stdio.transport().unwrap(), Transport::Stdio);
596 }
597
598 #[test]
599 fn ambiguous_and_empty_profiles_are_errors() {
600 let both: Profile =
601 toml::from_str("url = \"https://example/mcp\"\ncommand = [\"server\"]").unwrap();
602 assert!(both.transport().unwrap_err().contains("both"));
603 assert!(
604 Profile::default()
605 .transport()
606 .unwrap_err()
607 .contains("neither")
608 );
609 }
610
611 #[test]
612 fn declared_transport_must_have_its_fields() {
613 let profile: Profile = toml::from_str(r#"transport = "http""#).unwrap();
614 assert!(profile.resolve_with(env(&[])).unwrap_err().contains("url"));
615 let profile: Profile = toml::from_str(r#"transport = "stdio""#).unwrap();
616 assert!(
617 profile
618 .resolve_with(env(&[]))
619 .unwrap_err()
620 .contains("command")
621 );
622 }
623
624 #[test]
625 fn an_unsupported_transport_names_itself() {
626 let err =
627 Config::parse("[servers.x]\ntransport = \"ws\"\nurl = \"wss://example\"").unwrap_err();
628 assert!(err.contains("ws"), "{err}");
629 }
630
631 #[test]
632 fn aliases_parse_at_both_scopes() {
633 let config = Config::parse(
634 r#"
635[aliases]
636t = "tools"
637
638[servers.cratesio]
639url = "https://cratesio-mcp.fly.dev/"
640aliases = { dl = "get_downloads crate" }
641"#,
642 )
643 .unwrap();
644 assert_eq!(config.aliases.get("t").map(String::as_str), Some("tools"));
645 assert_eq!(
646 config.servers["cratesio"]
647 .aliases
648 .get("dl")
649 .map(String::as_str),
650 Some("get_downloads crate")
651 );
652 }
653
654 #[test]
655 fn a_config_without_aliases_parses_to_none_of_them() {
656 assert!(Config::parse(SAMPLE).unwrap().aliases.is_empty());
657 }
658
659 #[test]
660 fn a_typo_in_a_profile_key_is_rejected() {
661 let err =
662 Config::parse("[servers.x]\nurl = \"https://example\"\nbearrer = \"x\"").unwrap_err();
663 assert!(err.contains("bearrer"), "{err}");
664 }
665}