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}
58
59#[derive(Debug, Default, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct Profile {
63 pub transport: Option<Transport>,
65 pub url: Option<String>,
67 pub bearer: Option<String>,
69 pub bearer_env: Option<String>,
71 pub oauth: Option<String>,
73 #[serde(default)]
75 pub headers: BTreeMap<String, String>,
76 #[serde(default)]
78 pub command: Vec<String>,
79 #[serde(default)]
82 pub aliases: BTreeMap<String, String>,
83}
84
85#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct OAuthProfile {
89 pub url: String,
91 #[serde(default)]
93 pub scopes: Vec<String>,
94 pub client_id_metadata_document: Option<String>,
96 pub authorization_server: Option<String>,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum Transport {
106 Http,
107 Stdio,
108}
109
110#[derive(Debug, PartialEq, Eq)]
113pub enum Connection {
114 Http {
115 url: String,
116 bearer: Option<String>,
117 headers: Vec<(String, String)>,
118 oauth: Option<String>,
119 },
120 Stdio {
121 command: Vec<String>,
122 env: BTreeMap<String, String>,
123 cwd: Option<PathBuf>,
124 },
125}
126
127impl Config {
128 pub fn parse(source: &str) -> Result<Self, String> {
130 toml::from_str(source).map_err(|e| e.to_string())
131 }
132
133 pub fn load(path: &Path, explicit: bool) -> Result<Self, String> {
137 match std::fs::read_to_string(path) {
138 Ok(source) => Self::parse(&source).map_err(|e| format!("{}: {e}", path.display())),
139 Err(e) if e.kind() == std::io::ErrorKind::NotFound && !explicit => Ok(Self::default()),
140 Err(e) => Err(format!("{}: {e}", path.display())),
141 }
142 }
143
144 pub fn profile(&self, name: &str) -> Result<&Profile, String> {
147 self.servers.get(name).ok_or_else(|| {
148 if self.servers.is_empty() {
149 format!("no server profile named {name:?}: no profiles are configured")
150 } else {
151 format!(
152 "no server profile named {name:?}: known profiles are {}",
153 self.names().join(", ")
154 )
155 }
156 })
157 }
158
159 pub fn names(&self) -> Vec<&str> {
161 self.servers.keys().map(String::as_str).collect()
162 }
163
164 pub fn resolve_profile_with(
167 &self,
168 name: &str,
169 lookup: impl Fn(&str) -> Option<String>,
170 ) -> Result<Connection, String> {
171 let profile = self.profile(name)?;
172 let oauth_url = profile
173 .oauth
174 .as_deref()
175 .map(|oauth| {
176 self.oauth
177 .get(oauth)
178 .map(|metadata| metadata.url.as_str())
179 .ok_or_else(|| {
180 format!("server profile references unknown OAuth profile {oauth:?}")
181 })
182 })
183 .transpose()?;
184 profile.resolve_with_oauth_url(lookup, oauth_url)
185 }
186}
187
188impl Profile {
189 pub fn transport(&self) -> Result<Transport, String> {
192 match (
193 self.transport,
194 self.url.is_some() || self.oauth.is_some(),
195 !self.command.is_empty(),
196 ) {
197 (Some(t), _, _) => Ok(t),
198 (None, true, false) => Ok(Transport::Http),
199 (None, false, true) => Ok(Transport::Stdio),
200 (None, true, true) => Err(
201 "profile sets both `url` and `command`: add `transport = \"http\"` or \
202 `transport = \"stdio\"` to say which one applies"
203 .to_string(),
204 ),
205 (None, false, false) => {
206 Err("profile has neither `url` nor `command`, so it cannot connect".to_string())
207 }
208 }
209 }
210
211 pub fn bearer_token_with(
215 &self,
216 lookup: impl Fn(&str) -> Option<String>,
217 ) -> Result<Option<String>, String> {
218 if let Some(var) = &self.bearer_env {
219 return lookup(var).map(Some).ok_or_else(|| {
220 format!(
221 "profile sets `bearer_env = {var:?}` but that environment variable is unset"
222 )
223 });
224 }
225 Ok(self.bearer.clone())
226 }
227
228 #[cfg(test)]
231 pub fn resolve_with(
232 &self,
233 lookup: impl Fn(&str) -> Option<String>,
234 ) -> Result<Connection, String> {
235 self.resolve_with_oauth_url(lookup, None)
236 }
237
238 fn resolve_with_oauth_url(
239 &self,
240 lookup: impl Fn(&str) -> Option<String>,
241 oauth_url: Option<&str>,
242 ) -> Result<Connection, String> {
243 match self.transport()? {
244 Transport::Http => {
245 if self.oauth.is_some()
246 && (self.bearer.is_some()
247 || self.bearer_env.is_some()
248 || self
249 .headers
250 .keys()
251 .any(|name| name.eq_ignore_ascii_case("authorization")))
252 {
253 return Err(
254 "HTTP profile cannot combine `oauth` with `bearer`, `bearer_env`, or an \
255 Authorization header"
256 .to_string(),
257 );
258 }
259 let url = self
260 .url
261 .clone()
262 .or_else(|| oauth_url.map(str::to_string))
263 .ok_or("profile has `transport = \"http\"` but no `url`")?;
264 Ok(Connection::Http {
265 url,
266 bearer: self.bearer_token_with(lookup)?,
267 headers: self
268 .headers
269 .iter()
270 .map(|(k, v)| (k.clone(), v.clone()))
271 .collect(),
272 oauth: self.oauth.clone(),
273 })
274 }
275 Transport::Stdio => {
276 if self.command.is_empty() {
277 return Err("profile has `transport = \"stdio\"` but no `command`".to_string());
278 }
279 Ok(Connection::Stdio {
280 command: self.command.clone(),
281 env: BTreeMap::new(),
282 cwd: None,
283 })
284 }
285 }
286 }
287
288 pub fn summary(&self) -> String {
290 match self.transport() {
291 Ok(Transport::Http) => format!(
292 "http {}",
293 self.url
294 .as_deref()
295 .or(self.oauth.as_deref())
296 .unwrap_or("(no url)")
297 ),
298 Ok(Transport::Stdio) => format!("stdio {}", self.command.join(" ")),
299 Err(e) => format!("(invalid: {e})"),
300 }
301 }
302}
303
304pub fn config_path(explicit: Option<&str>) -> Option<(PathBuf, bool)> {
309 if let Some(p) = explicit {
310 return Some((PathBuf::from(p), true));
311 }
312 let base = match std::env::var_os("XDG_CONFIG_HOME") {
313 Some(x) if !x.is_empty() => PathBuf::from(x),
314 _ => {
315 let mut home = PathBuf::from(std::env::var_os("HOME")?);
316 home.push(".config");
317 home
318 }
319 };
320 Some((base.join("mcp-repl").join("config.toml"), false))
321}
322
323#[cfg(test)]
324mod tests {
325 use super::*;
326
327 const SAMPLE: &str = r#"
328[servers.cratesio]
329transport = "http"
330url = "https://cratesio-mcp.fly.dev/"
331bearer_env = "CRATESIO_TOKEN"
332headers = { "X-Api-Key" = "abc" }
333
334[servers.local]
335transport = "stdio"
336command = ["cargo", "run", "--example", "getting_started"]
337"#;
338
339 fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
340 let map: BTreeMap<String, String> = pairs
341 .iter()
342 .map(|(k, v)| (k.to_string(), v.to_string()))
343 .collect();
344 move |k: &str| map.get(k).cloned()
345 }
346
347 #[test]
348 fn parses_named_profiles() {
349 let config = Config::parse(SAMPLE).unwrap();
350 assert_eq!(config.names(), vec!["cratesio", "local"]);
351 }
352
353 #[test]
354 fn http_profile_resolves_transport_and_auth() {
355 let config = Config::parse(SAMPLE).unwrap();
356 let resolved = config
357 .profile("cratesio")
358 .unwrap()
359 .resolve_with(env(&[("CRATESIO_TOKEN", "secret")]))
360 .unwrap();
361 assert_eq!(
362 resolved,
363 Connection::Http {
364 url: "https://cratesio-mcp.fly.dev/".to_string(),
365 bearer: Some("secret".to_string()),
366 headers: vec![("X-Api-Key".to_string(), "abc".to_string())],
367 oauth: None,
368 }
369 );
370 }
371
372 #[test]
373 fn oauth_metadata_and_server_selection_are_non_secret() {
374 let config = Config::parse(
375 r#"
376[oauth.work]
377url = "https://mcp.example/mcp"
378scopes = ["openid", "offline_access"]
379client_id_metadata_document = "https://client.example/metadata.json"
380authorization_server = "https://auth.example"
381
382[servers.work]
383oauth = "work"
384headers = { "X-Tenant" = "acme" }
385"#,
386 )
387 .unwrap();
388
389 assert_eq!(config.oauth["work"].scopes, ["openid", "offline_access"]);
390 assert_eq!(
391 config.resolve_profile_with("work", env(&[])).unwrap(),
392 Connection::Http {
393 url: "https://mcp.example/mcp".to_string(),
394 bearer: None,
395 headers: vec![("X-Tenant".to_string(), "acme".to_string())],
396 oauth: Some("work".to_string()),
397 }
398 );
399 }
400
401 #[test]
402 fn unknown_oauth_reference_is_an_actionable_error() {
403 let config = Config::parse("[servers.work]\noauth = \"missing\"\n").unwrap();
404 let error = config.resolve_profile_with("work", env(&[])).unwrap_err();
405 assert!(
406 error.contains("unknown OAuth profile \"missing\""),
407 "{error}"
408 );
409 }
410
411 #[test]
412 fn oauth_server_profile_rejects_ambiguous_static_auth() {
413 for auth in [
414 "bearer = \"secret\"",
415 "bearer_env = \"TOKEN\"",
416 "headers = { Authorization = \"Bearer secret\" }",
417 ] {
418 let source = format!(
419 "[servers.work]\nurl = \"https://mcp.example/mcp\"\noauth = \"work\"\n{auth}\n"
420 );
421 let error = Config::parse(&source)
422 .unwrap()
423 .profile("work")
424 .unwrap()
425 .resolve_with(env(&[("TOKEN", "secret")]))
426 .unwrap_err();
427 assert!(error.contains("cannot combine `oauth`"), "{error}");
428 }
429 }
430
431 #[test]
432 fn stdio_profile_resolves_command() {
433 let config = Config::parse(SAMPLE).unwrap();
434 let resolved = config
435 .profile("local")
436 .unwrap()
437 .resolve_with(env(&[]))
438 .unwrap();
439 assert_eq!(
440 resolved,
441 Connection::Stdio {
442 command: vec![
443 "cargo".to_string(),
444 "run".to_string(),
445 "--example".to_string(),
446 "getting_started".to_string(),
447 ],
448 env: BTreeMap::new(),
449 cwd: None,
450 }
451 );
452 }
453
454 #[test]
455 fn unknown_profile_lists_known_names() {
456 let config = Config::parse(SAMPLE).unwrap();
457 let err = config.profile("nope").unwrap_err();
458 assert!(err.contains("nope"), "{err}");
459 assert!(err.contains("cratesio, local"), "{err}");
460 }
461
462 #[test]
463 fn unknown_profile_with_empty_config_says_so() {
464 let err = Config::default().profile("nope").unwrap_err();
465 assert!(err.contains("no profiles are configured"), "{err}");
466 }
467
468 #[test]
469 fn unset_bearer_env_is_an_error() {
470 let config = Config::parse(SAMPLE).unwrap();
471 let err = config
472 .profile("cratesio")
473 .unwrap()
474 .resolve_with(env(&[]))
475 .unwrap_err();
476 assert!(err.contains("CRATESIO_TOKEN"), "{err}");
477 }
478
479 #[test]
480 fn inline_bearer_is_used_when_no_env_indirection() {
481 let profile: Profile = toml::from_str(
482 r#"
483 url = "https://example/mcp"
484 bearer = "literal"
485 "#,
486 )
487 .unwrap();
488 assert_eq!(
489 profile.bearer_token_with(env(&[])).unwrap(),
490 Some("literal".to_string())
491 );
492 }
493
494 #[test]
495 fn transport_is_inferred_from_the_fields() {
496 let http: Profile = toml::from_str(r#"url = "https://example/mcp""#).unwrap();
497 assert_eq!(http.transport().unwrap(), Transport::Http);
498 let stdio: Profile = toml::from_str(r#"command = ["server"]"#).unwrap();
499 assert_eq!(stdio.transport().unwrap(), Transport::Stdio);
500 }
501
502 #[test]
503 fn ambiguous_and_empty_profiles_are_errors() {
504 let both: Profile =
505 toml::from_str("url = \"https://example/mcp\"\ncommand = [\"server\"]").unwrap();
506 assert!(both.transport().unwrap_err().contains("both"));
507 assert!(
508 Profile::default()
509 .transport()
510 .unwrap_err()
511 .contains("neither")
512 );
513 }
514
515 #[test]
516 fn declared_transport_must_have_its_fields() {
517 let profile: Profile = toml::from_str(r#"transport = "http""#).unwrap();
518 assert!(profile.resolve_with(env(&[])).unwrap_err().contains("url"));
519 let profile: Profile = toml::from_str(r#"transport = "stdio""#).unwrap();
520 assert!(
521 profile
522 .resolve_with(env(&[]))
523 .unwrap_err()
524 .contains("command")
525 );
526 }
527
528 #[test]
529 fn an_unsupported_transport_names_itself() {
530 let err =
531 Config::parse("[servers.x]\ntransport = \"ws\"\nurl = \"wss://example\"").unwrap_err();
532 assert!(err.contains("ws"), "{err}");
533 }
534
535 #[test]
536 fn aliases_parse_at_both_scopes() {
537 let config = Config::parse(
538 r#"
539[aliases]
540t = "tools"
541
542[servers.cratesio]
543url = "https://cratesio-mcp.fly.dev/"
544aliases = { dl = "get_downloads crate" }
545"#,
546 )
547 .unwrap();
548 assert_eq!(config.aliases.get("t").map(String::as_str), Some("tools"));
549 assert_eq!(
550 config.servers["cratesio"]
551 .aliases
552 .get("dl")
553 .map(String::as_str),
554 Some("get_downloads crate")
555 );
556 }
557
558 #[test]
559 fn a_config_without_aliases_parses_to_none_of_them() {
560 assert!(Config::parse(SAMPLE).unwrap().aliases.is_empty());
561 }
562
563 #[test]
564 fn a_typo_in_a_profile_key_is_rejected() {
565 let err =
566 Config::parse("[servers.x]\nurl = \"https://example\"\nbearrer = \"x\"").unwrap_err();
567 assert!(err.contains("bearrer"), "{err}");
568 }
569}