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    /// Settings for the REPL itself rather than for any one server.
58    #[serde(default)]
59    pub repl: Repl,
60}
61
62/// The `[repl]` table: knobs that are not about a connection.
63#[derive(Debug, Default, Deserialize)]
64#[serde(deny_unknown_fields)]
65pub struct Repl {
66    /// How many lines of command history to keep. `0` disables persistence
67    /// as surely as `--no-history` does.
68    pub history_capacity: Option<usize>,
69    /// Seconds to allow a request before giving up, when `--timeout` does not
70    /// say. `0` waits indefinitely, exactly as the flag's `0` does.
71    pub request_timeout: Option<u64>,
72    /// Milliseconds to wait for a server to answer `completion/complete`
73    /// while the user is mid-word. Short on purpose: this runs between
74    /// keystrokes, and a menu that arrives late is worse than one that does
75    /// not arrive.
76    pub completion_timeout_ms: Option<u64>,
77}
78
79/// One `[servers.<name>]` table.
80#[derive(Debug, Default, Deserialize)]
81#[serde(deny_unknown_fields)]
82pub struct Profile {
83    /// `http` or `stdio`. Optional: inferred from `url`/`command` when absent.
84    pub transport: Option<Transport>,
85    /// The endpoint for an `http` profile.
86    pub url: Option<String>,
87    /// An inline bearer token. Prefer `bearer_env`; this warns when used.
88    pub bearer: Option<String>,
89    /// Name of the environment variable holding the bearer token.
90    pub bearer_env: Option<String>,
91    /// Named entry in the top-level `[oauth]` table.
92    pub oauth: Option<String>,
93    /// Extra headers sent with every request of an `http` profile.
94    #[serde(default)]
95    pub headers: BTreeMap<String, String>,
96    /// The command (and arguments) of a `stdio` profile's child process.
97    #[serde(default)]
98    pub command: Vec<String>,
99    /// Command aliases in effect only through this profile. They shadow the
100    /// file-level `[aliases]` of the same name.
101    #[serde(default)]
102    pub aliases: BTreeMap<String, String>,
103}
104
105/// Non-secret OAuth metadata stored under `[oauth.<name>]`.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
107#[serde(deny_unknown_fields)]
108pub struct OAuthProfile {
109    /// MCP protected-resource URL used when this profile was authorized.
110    pub url: String,
111    /// Initial scopes requested during login and tracked for escalation.
112    #[serde(default)]
113    pub scopes: Vec<String>,
114    /// Optional HTTPS Client ID Metadata Document URL (CIMD).
115    pub client_id_metadata_document: Option<String>,
116    /// Preferred authorization-server issuer when discovery advertises several.
117    pub authorization_server: Option<String>,
118}
119
120/// The transports a profile can name. `ws` and stateless HTTP are not
121/// profile-addressable yet, so an unknown value is a config error rather than
122/// a silent fallback.
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
124#[serde(rename_all = "lowercase")]
125pub enum Transport {
126    Http,
127    Stdio,
128}
129
130/// A profile resolved into everything needed to connect. Produced after the
131/// CLI flags have had their say.
132#[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    /// Parse a config file's contents.
149    pub fn parse(source: &str) -> Result<Self, String> {
150        toml::from_str(source).map_err(|e| e.to_string())
151    }
152
153    /// Read the config from `path`. A missing file is an error only when the
154    /// path was explicitly requested (`--config`); the default location is
155    /// allowed not to exist.
156    pub fn load(path: &Path, explicit: bool) -> Result<Self, String> {
157        match std::fs::read_to_string(path) {
158            Ok(source) => {
159                // The file may hold an inline bearer or an Authorization
160                // header. One written before this behavior existed, or by
161                // an older release, is tightened here rather than waiting
162                // for the next write to replace it.
163                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    /// Look up a profile by name, with an error listing the known names when
172    /// it is missing.
173    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    /// The configured profile names, sorted.
187    pub fn names(&self) -> Vec<&str> {
188        self.servers.keys().map(String::as_str).collect()
189    }
190
191    /// Resolve a named server, allowing an OAuth-only server profile to reuse
192    /// the protected-resource URL saved with its credential profile.
193    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    /// The transport this profile connects over: the declared one, else
217    /// inferred from whichever of `url`/`command` is present.
218    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    /// The bearer token for this profile: `bearer_env` read from `lookup`, or
239    /// the inline `bearer`. A `bearer_env` naming an unset variable is an
240    /// error, not a silent anonymous connection.
241    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    /// Resolve into a [`Connection`], validating that the transport has the
256    /// fields it needs.
257    #[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    /// A one-line summary for `--list-servers`.
316    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
331/// The config file location: `--config` if given, else
332/// `$XDG_CONFIG_HOME/mcp-repl/config.toml`, else `~/.config/mcp-repl/config.toml`.
333/// The bool is true when the path was explicitly requested, which makes a
334/// missing file an error.
335pub 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        // Absent section, absent keys: every consumer falls back to its own
377        // default, so adding a key here cannot change behaviour by itself.
378        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    /// A silently ignored typo in a config file is worse than a refusal: the
401    /// setting appears to be applied and is not.
402    #[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    /// `0` is a value, not an absence: it means "wait indefinitely" for the
412    /// timeout and "keep no history" for the capacity.
413    #[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}