Skip to main content

mcp_repl/
config.rs

1//! Server profiles: a config file of named servers, so a remote MCP server
2//! can be reached as `mcp-repl <name>` instead of a URL plus repeated
3//! `--bearer`/`--header` flags.
4//!
5//! The file lives at `$XDG_CONFIG_HOME/mcp-repl/config.toml`, falling back to
6//! `~/.config/mcp-repl/config.toml`, and `--config <path>` overrides it:
7//!
8//! ```toml
9//! [servers.cratesio]
10//! transport = "http"
11//! url = "https://cratesio-mcp.fly.dev/"
12//! bearer_env = "CRATESIO_TOKEN"
13//! headers = { "X-Api-Key" = "..." }
14//!
15//! [oauth.work]
16//! url = "https://mcp.example.com/mcp"
17//! scopes = ["openid", "offline_access"]
18//!
19//! [servers.work]
20//! transport = "http"
21//! oauth = "work"
22//!
23//! [servers.local]
24//! transport = "stdio"
25//! command = ["cargo", "run", "--example", "getting_started"]
26//!
27//! [aliases]
28//! t = "tools"
29//! ```
30//!
31//! Command aliases live in the same file: `[aliases]` for every server, and
32//! `[servers.<name>.aliases]` for one profile. The interactive `alias` and
33//! `unalias` commands write them back.
34//!
35//! Tokens are read from the environment via `bearer_env` rather than stored in
36//! the file; an inline `bearer` literal works but warns.
37
38use std::collections::BTreeMap;
39use std::path::{Path, PathBuf};
40
41use serde::Deserialize;
42
43/// The whole config file: named profiles under `[servers.<name>]`, plus the
44/// command aliases every server sees under `[aliases]`.
45#[derive(Debug, Default, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct Config {
48    #[serde(default)]
49    pub servers: BTreeMap<String, Profile>,
50    /// Non-secret metadata for named OAuth credential profiles. Tokens and
51    /// registered client secrets live in the operating-system credential store.
52    #[serde(default)]
53    pub oauth: BTreeMap<String, OAuthProfile>,
54    /// Command aliases in effect against every server.
55    #[serde(default)]
56    pub aliases: BTreeMap<String, String>,
57}
58
59/// One `[servers.<name>]` table.
60#[derive(Debug, Default, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct Profile {
63    /// `http` or `stdio`. Optional: inferred from `url`/`command` when absent.
64    pub transport: Option<Transport>,
65    /// The endpoint for an `http` profile.
66    pub url: Option<String>,
67    /// An inline bearer token. Prefer `bearer_env`; this warns when used.
68    pub bearer: Option<String>,
69    /// Name of the environment variable holding the bearer token.
70    pub bearer_env: Option<String>,
71    /// Named entry in the top-level `[oauth]` table.
72    pub oauth: Option<String>,
73    /// Extra headers sent with every request of an `http` profile.
74    #[serde(default)]
75    pub headers: BTreeMap<String, String>,
76    /// The command (and arguments) of a `stdio` profile's child process.
77    #[serde(default)]
78    pub command: Vec<String>,
79    /// Command aliases in effect only through this profile. They shadow the
80    /// file-level `[aliases]` of the same name.
81    #[serde(default)]
82    pub aliases: BTreeMap<String, String>,
83}
84
85/// Non-secret OAuth metadata stored under `[oauth.<name>]`.
86#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
87#[serde(deny_unknown_fields)]
88pub struct OAuthProfile {
89    /// MCP protected-resource URL used when this profile was authorized.
90    pub url: String,
91    /// Initial scopes requested during login and tracked for escalation.
92    #[serde(default)]
93    pub scopes: Vec<String>,
94    /// Optional HTTPS Client ID Metadata Document URL (CIMD).
95    pub client_id_metadata_document: Option<String>,
96    /// Preferred authorization-server issuer when discovery advertises several.
97    pub authorization_server: Option<String>,
98}
99
100/// The transports a profile can name. `ws` and stateless HTTP are not
101/// profile-addressable yet, so an unknown value is a config error rather than
102/// a silent fallback.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
104#[serde(rename_all = "lowercase")]
105pub enum Transport {
106    Http,
107    Stdio,
108}
109
110/// A profile resolved into everything needed to connect. Produced after the
111/// CLI flags have had their say.
112#[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    /// Parse a config file's contents.
129    pub fn parse(source: &str) -> Result<Self, String> {
130        toml::from_str(source).map_err(|e| e.to_string())
131    }
132
133    /// Read the config from `path`. A missing file is an error only when the
134    /// path was explicitly requested (`--config`); the default location is
135    /// allowed not to exist.
136    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    /// Look up a profile by name, with an error listing the known names when
145    /// it is missing.
146    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    /// The configured profile names, sorted.
160    pub fn names(&self) -> Vec<&str> {
161        self.servers.keys().map(String::as_str).collect()
162    }
163
164    /// Resolve a named server, allowing an OAuth-only server profile to reuse
165    /// the protected-resource URL saved with its credential profile.
166    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    /// The transport this profile connects over: the declared one, else
190    /// inferred from whichever of `url`/`command` is present.
191    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    /// The bearer token for this profile: `bearer_env` read from `lookup`, or
212    /// the inline `bearer`. A `bearer_env` naming an unset variable is an
213    /// error, not a silent anonymous connection.
214    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    /// Resolve into a [`Connection`], validating that the transport has the
229    /// fields it needs.
230    #[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    /// A one-line summary for `--list-servers`.
289    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
304/// The config file location: `--config` if given, else
305/// `$XDG_CONFIG_HOME/mcp-repl/config.toml`, else `~/.config/mcp-repl/config.toml`.
306/// The bool is true when the path was explicitly requested, which makes a
307/// missing file an error.
308pub 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}