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//! On Unix the file lives at `$XDG_CONFIG_HOME/mcp-repl/config.toml`, falling
6//! back to `~/.config/mcp-repl/config.toml`. On Windows it lives below
7//! `%APPDATA%\mcp-repl`. `--config <path>` overrides either default:
8//!
9//! ```toml
10//! [servers.cratesio]
11//! transport = "http"
12//! url = "https://cratesio-mcp.fly.dev/"
13//! bearer_env = "CRATESIO_TOKEN"
14//! headers = { "X-Api-Key" = "..." }
15//!
16//! [oauth.work]
17//! url = "https://mcp.example.com/mcp"
18//! scopes = ["openid", "offline_access"]
19//!
20//! [servers.work]
21//! transport = "http"
22//! oauth = "work"
23//!
24//! [servers.local]
25//! transport = "stdio"
26//! command = ["cargo", "run", "--example", "getting_started"]
27//!
28//! [aliases]
29//! t = "tools"
30//! ```
31//!
32//! Command aliases live in the same file: `[aliases]` for every server, and
33//! `[servers.<name>.aliases]` for one profile. The interactive `alias` and
34//! `unalias` commands write them back.
35//!
36//! Tokens are read from the environment via `bearer_env` rather than stored in
37//! the file; an inline `bearer` literal works but warns.
38
39use std::collections::BTreeMap;
40use std::path::{Path, PathBuf};
41
42use serde::Deserialize;
43
44/// The whole config file: named profiles under `[servers.<name>]`, plus the
45/// command aliases every server sees under `[aliases]`.
46#[derive(Debug, Default, Deserialize)]
47#[serde(deny_unknown_fields)]
48pub struct Config {
49    #[serde(default)]
50    pub servers: BTreeMap<String, Profile>,
51    /// Non-secret metadata for named OAuth credential profiles. Tokens and
52    /// registered client secrets live in the operating-system credential store.
53    #[serde(default)]
54    pub oauth: BTreeMap<String, OAuthProfile>,
55    /// Command aliases in effect against every server.
56    #[serde(default)]
57    pub aliases: BTreeMap<String, String>,
58    /// Settings for the REPL itself rather than for any one server.
59    #[serde(default)]
60    pub repl: Repl,
61}
62
63/// The `[repl]` table: knobs that are not about a connection.
64#[derive(Debug, Default, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct Repl {
67    /// How many lines of command history to keep. `0` disables persistence
68    /// as surely as `--no-history` does.
69    pub history_capacity: Option<usize>,
70    /// Seconds to allow a request before giving up, when `--timeout` does not
71    /// say. `0` waits indefinitely, exactly as the flag's `0` does.
72    pub request_timeout: Option<u64>,
73    /// Milliseconds to wait for a server to answer `completion/complete`
74    /// while the user is mid-word. Short on purpose: this runs between
75    /// keystrokes, and a menu that arrives late is worse than one that does
76    /// not arrive.
77    pub completion_timeout_ms: Option<u64>,
78}
79
80/// One `[servers.<name>]` table.
81#[derive(Debug, Default, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct Profile {
84    /// `http` or `stdio`. Optional: inferred from `url`/`command` when absent.
85    pub transport: Option<Transport>,
86    /// The endpoint for an `http` profile.
87    pub url: Option<String>,
88    /// An inline bearer token. Prefer `bearer_env`; this warns when used.
89    pub bearer: Option<String>,
90    /// Name of the environment variable holding the bearer token.
91    pub bearer_env: Option<String>,
92    /// Named entry in the top-level `[oauth]` table.
93    pub oauth: Option<String>,
94    /// Extra headers sent with every request of an `http` profile.
95    #[serde(default)]
96    pub headers: BTreeMap<String, String>,
97    /// The command (and arguments) of a `stdio` profile's child process.
98    #[serde(default)]
99    pub command: Vec<String>,
100    /// Command aliases in effect only through this profile. They shadow the
101    /// file-level `[aliases]` of the same name.
102    #[serde(default)]
103    pub aliases: BTreeMap<String, String>,
104}
105
106/// Non-secret OAuth metadata stored under `[oauth.<name>]`.
107#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct OAuthProfile {
110    /// MCP protected-resource URL used when this profile was authorized.
111    pub url: String,
112    /// Initial scopes requested during login and tracked for escalation.
113    #[serde(default)]
114    pub scopes: Vec<String>,
115    /// Optional HTTPS Client ID Metadata Document URL (CIMD).
116    pub client_id_metadata_document: Option<String>,
117    /// Preferred authorization-server issuer when discovery advertises several.
118    pub authorization_server: Option<String>,
119}
120
121/// The transports a profile can name. `ws` and stateless HTTP are not
122/// profile-addressable yet, so an unknown value is a config error rather than
123/// a silent fallback.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
125#[serde(rename_all = "lowercase")]
126pub enum Transport {
127    Http,
128    Stdio,
129}
130
131/// A profile resolved into everything needed to connect. Produced after the
132/// CLI flags have had their say.
133#[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    /// Parse a config file's contents.
150    pub fn parse(source: &str) -> Result<Self, String> {
151        toml::from_str(source).map_err(|e| e.to_string())
152    }
153
154    /// Read the config from `path`. A missing file is an error only when the
155    /// path was explicitly requested (`--config`); the default location is
156    /// allowed not to exist.
157    pub fn load(path: &Path, explicit: bool) -> Result<Self, String> {
158        match std::fs::read_to_string(path) {
159            Ok(source) => {
160                // The file may hold an inline bearer or an Authorization
161                // header. One written before this behavior existed, or by
162                // an older release, is tightened here rather than waiting
163                // for the next write to replace it.
164                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    /// Look up a profile by name, with an error listing the known names when
173    /// it is missing.
174    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    /// The configured profile names, sorted.
188    pub fn names(&self) -> Vec<&str> {
189        self.servers.keys().map(String::as_str).collect()
190    }
191
192    /// Resolve a named server, allowing an OAuth-only server profile to reuse
193    /// the protected-resource URL saved with its credential profile.
194    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    /// The transport this profile connects over: the declared one, else
218    /// inferred from whichever of `url`/`command` is present.
219    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    /// The bearer token for this profile: `bearer_env` read from `lookup`, or
240    /// the inline `bearer`. A `bearer_env` naming an unset variable is an
241    /// error, not a silent anonymous connection.
242    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    /// Resolve into a [`Connection`], validating that the transport has the
257    /// fields it needs.
258    #[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    /// A one-line summary for `--list-servers`.
317    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
332/// The config file location: `--config` if given, else
333/// the platform-native mcp-repl config directory.
334/// The bool is true when the path was explicitly requested, which makes a
335/// missing file an error.
336pub 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        // Absent section, absent keys: every consumer falls back to its own
399        // default, so adding a key here cannot change behaviour by itself.
400        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    /// A silently ignored typo in a config file is worse than a refusal: the
423    /// setting appears to be applied and is not.
424    #[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    /// `0` is a value, not an absence: it means "wait indefinitely" for the
434    /// timeout and "keep no history" for the capacity.
435    #[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}