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