use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
pub servers: BTreeMap<String, Profile>,
#[serde(default)]
pub oauth: BTreeMap<String, OAuthProfile>,
#[serde(default)]
pub aliases: BTreeMap<String, String>,
#[serde(default)]
pub repl: Repl,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Repl {
pub history_capacity: Option<usize>,
pub request_timeout: Option<u64>,
pub completion_timeout_ms: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Profile {
pub transport: Option<Transport>,
pub url: Option<String>,
pub bearer: Option<String>,
pub bearer_env: Option<String>,
pub oauth: Option<String>,
#[serde(default)]
pub headers: BTreeMap<String, String>,
#[serde(default)]
pub command: Vec<String>,
#[serde(default)]
pub aliases: BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct OAuthProfile {
pub url: String,
#[serde(default)]
pub scopes: Vec<String>,
pub client_id_metadata_document: Option<String>,
pub authorization_server: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Transport {
Http,
Stdio,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Connection {
Http {
url: String,
bearer: Option<String>,
headers: Vec<(String, String)>,
oauth: Option<String>,
},
Stdio {
command: Vec<String>,
env: BTreeMap<String, String>,
cwd: Option<PathBuf>,
},
}
impl Config {
pub fn parse(source: &str) -> Result<Self, String> {
toml::from_str(source).map_err(|e| e.to_string())
}
pub fn load(path: &Path, explicit: bool) -> Result<Self, String> {
match std::fs::read_to_string(path) {
Ok(source) => {
crate::secure_file::restrict_existing(path);
Self::parse(&source).map_err(|e| format!("{}: {e}", path.display()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && !explicit => Ok(Self::default()),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
pub fn profile(&self, name: &str) -> Result<&Profile, String> {
self.servers.get(name).ok_or_else(|| {
if self.servers.is_empty() {
format!("no server profile named {name:?}: no profiles are configured")
} else {
format!(
"no server profile named {name:?}: known profiles are {}",
self.names().join(", ")
)
}
})
}
pub fn names(&self) -> Vec<&str> {
self.servers.keys().map(String::as_str).collect()
}
pub fn resolve_profile_with(
&self,
name: &str,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<Connection, String> {
let profile = self.profile(name)?;
let oauth_url = profile
.oauth
.as_deref()
.map(|oauth| {
self.oauth
.get(oauth)
.map(|metadata| metadata.url.as_str())
.ok_or_else(|| {
format!("server profile references unknown OAuth profile {oauth:?}")
})
})
.transpose()?;
profile.resolve_with_oauth_url(lookup, oauth_url)
}
}
impl Profile {
pub fn transport(&self) -> Result<Transport, String> {
match (
self.transport,
self.url.is_some() || self.oauth.is_some(),
!self.command.is_empty(),
) {
(Some(t), _, _) => Ok(t),
(None, true, false) => Ok(Transport::Http),
(None, false, true) => Ok(Transport::Stdio),
(None, true, true) => Err(
"profile sets both `url` and `command`: add `transport = \"http\"` or \
`transport = \"stdio\"` to say which one applies"
.to_string(),
),
(None, false, false) => {
Err("profile has neither `url` nor `command`, so it cannot connect".to_string())
}
}
}
pub fn bearer_token_with(
&self,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<Option<String>, String> {
if let Some(var) = &self.bearer_env {
return lookup(var).map(Some).ok_or_else(|| {
format!(
"profile sets `bearer_env = {var:?}` but that environment variable is unset"
)
});
}
Ok(self.bearer.clone())
}
#[cfg(test)]
pub fn resolve_with(
&self,
lookup: impl Fn(&str) -> Option<String>,
) -> Result<Connection, String> {
self.resolve_with_oauth_url(lookup, None)
}
fn resolve_with_oauth_url(
&self,
lookup: impl Fn(&str) -> Option<String>,
oauth_url: Option<&str>,
) -> Result<Connection, String> {
match self.transport()? {
Transport::Http => {
if self.oauth.is_some()
&& (self.bearer.is_some()
|| self.bearer_env.is_some()
|| self
.headers
.keys()
.any(|name| name.eq_ignore_ascii_case("authorization")))
{
return Err(
"HTTP profile cannot combine `oauth` with `bearer`, `bearer_env`, or an \
Authorization header"
.to_string(),
);
}
let url = self
.url
.clone()
.or_else(|| oauth_url.map(str::to_string))
.ok_or("profile has `transport = \"http\"` but no `url`")?;
Ok(Connection::Http {
url,
bearer: self.bearer_token_with(lookup)?,
headers: self
.headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
oauth: self.oauth.clone(),
})
}
Transport::Stdio => {
if self.command.is_empty() {
return Err("profile has `transport = \"stdio\"` but no `command`".to_string());
}
Ok(Connection::Stdio {
command: self.command.clone(),
env: BTreeMap::new(),
cwd: None,
})
}
}
}
pub fn summary(&self) -> String {
match self.transport() {
Ok(Transport::Http) => format!(
"http {}",
self.url
.as_deref()
.or(self.oauth.as_deref())
.unwrap_or("(no url)")
),
Ok(Transport::Stdio) => format!("stdio {}", self.command.join(" ")),
Err(e) => format!("(invalid: {e})"),
}
}
}
pub fn config_path(explicit: Option<&str>) -> Option<(PathBuf, bool)> {
config_path_with(explicit, &crate::directories::Directories::current())
}
fn config_path_with(
explicit: Option<&str>,
directories: &crate::directories::Directories,
) -> Option<(PathBuf, bool)> {
if let Some(p) = explicit {
return Some((PathBuf::from(p), true));
}
Some((directories.config_file()?, false))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::directories::{Directories, Platform};
use std::ffi::OsString;
#[test]
fn windows_directories_drive_the_default_config_path() {
let directories = Directories::from_lookup(Platform::Windows, |name| {
(name == "APPDATA").then(|| OsString::from(r"C:\Users\Ada\AppData\Roaming"))
});
assert_eq!(
config_path_with(None, &directories),
Some((
PathBuf::from(r"C:\Users\Ada\AppData\Roaming")
.join("mcp-repl")
.join("config.toml"),
false
))
);
assert_eq!(
config_path_with(Some("portable.toml"), &directories),
Some((PathBuf::from("portable.toml"), true))
);
}
const SAMPLE: &str = r#"
[servers.cratesio]
transport = "http"
url = "https://cratesio-mcp.fly.dev/"
bearer_env = "CRATESIO_TOKEN"
headers = { "X-Api-Key" = "abc" }
[servers.local]
transport = "stdio"
command = ["cargo", "run", "--example", "getting_started"]
"#;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let map: BTreeMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k: &str| map.get(k).cloned()
}
#[test]
fn the_repl_table_is_optional_and_defaults_to_nothing_set() {
let config: Config = toml::from_str(SAMPLE).expect("parses");
assert_eq!(config.repl.history_capacity, None);
assert_eq!(config.repl.request_timeout, None);
assert_eq!(config.repl.completion_timeout_ms, None);
}
#[test]
fn the_repl_table_parses_its_tunables() {
let config: Config = toml::from_str(
r#"
[repl]
history_capacity = 50
request_timeout = 7
completion_timeout_ms = 250
"#,
)
.expect("parses");
assert_eq!(config.repl.history_capacity, Some(50));
assert_eq!(config.repl.request_timeout, Some(7));
assert_eq!(config.repl.completion_timeout_ms, Some(250));
}
#[test]
fn a_misspelled_repl_key_is_refused_and_names_the_alternatives() {
let error = toml::from_str::<Config>("[repl]\nhistory_capacty = 50\n")
.expect_err("a typo is an error");
let message = error.to_string();
assert!(message.contains("history_capacty"), "{message}");
assert!(message.contains("history_capacity"), "{message}");
}
#[test]
fn zero_is_a_setting_rather_than_an_unset_key() {
let config: Config =
toml::from_str("[repl]\nhistory_capacity = 0\nrequest_timeout = 0\n").expect("parses");
assert_eq!(config.repl.history_capacity, Some(0));
assert_eq!(config.repl.request_timeout, Some(0));
}
#[test]
fn parses_named_profiles() {
let config = Config::parse(SAMPLE).unwrap();
assert_eq!(config.names(), vec!["cratesio", "local"]);
}
#[test]
fn http_profile_resolves_transport_and_auth() {
let config = Config::parse(SAMPLE).unwrap();
let resolved = config
.profile("cratesio")
.unwrap()
.resolve_with(env(&[("CRATESIO_TOKEN", "secret")]))
.unwrap();
assert_eq!(
resolved,
Connection::Http {
url: "https://cratesio-mcp.fly.dev/".to_string(),
bearer: Some("secret".to_string()),
headers: vec![("X-Api-Key".to_string(), "abc".to_string())],
oauth: None,
}
);
}
#[test]
fn oauth_metadata_and_server_selection_are_non_secret() {
let config = Config::parse(
r#"
[oauth.work]
url = "https://mcp.example/mcp"
scopes = ["openid", "offline_access"]
client_id_metadata_document = "https://client.example/metadata.json"
authorization_server = "https://auth.example"
[servers.work]
oauth = "work"
headers = { "X-Tenant" = "acme" }
"#,
)
.unwrap();
assert_eq!(config.oauth["work"].scopes, ["openid", "offline_access"]);
assert_eq!(
config.resolve_profile_with("work", env(&[])).unwrap(),
Connection::Http {
url: "https://mcp.example/mcp".to_string(),
bearer: None,
headers: vec![("X-Tenant".to_string(), "acme".to_string())],
oauth: Some("work".to_string()),
}
);
}
#[test]
fn unknown_oauth_reference_is_an_actionable_error() {
let config = Config::parse("[servers.work]\noauth = \"missing\"\n").unwrap();
let error = config.resolve_profile_with("work", env(&[])).unwrap_err();
assert!(
error.contains("unknown OAuth profile \"missing\""),
"{error}"
);
}
#[test]
fn oauth_server_profile_rejects_ambiguous_static_auth() {
for auth in [
"bearer = \"secret\"",
"bearer_env = \"TOKEN\"",
"headers = { Authorization = \"Bearer secret\" }",
] {
let source = format!(
"[servers.work]\nurl = \"https://mcp.example/mcp\"\noauth = \"work\"\n{auth}\n"
);
let error = Config::parse(&source)
.unwrap()
.profile("work")
.unwrap()
.resolve_with(env(&[("TOKEN", "secret")]))
.unwrap_err();
assert!(error.contains("cannot combine `oauth`"), "{error}");
}
}
#[test]
fn stdio_profile_resolves_command() {
let config = Config::parse(SAMPLE).unwrap();
let resolved = config
.profile("local")
.unwrap()
.resolve_with(env(&[]))
.unwrap();
assert_eq!(
resolved,
Connection::Stdio {
command: vec![
"cargo".to_string(),
"run".to_string(),
"--example".to_string(),
"getting_started".to_string(),
],
env: BTreeMap::new(),
cwd: None,
}
);
}
#[test]
fn unknown_profile_lists_known_names() {
let config = Config::parse(SAMPLE).unwrap();
let err = config.profile("nope").unwrap_err();
assert!(err.contains("nope"), "{err}");
assert!(err.contains("cratesio, local"), "{err}");
}
#[test]
fn unknown_profile_with_empty_config_says_so() {
let err = Config::default().profile("nope").unwrap_err();
assert!(err.contains("no profiles are configured"), "{err}");
}
#[test]
fn unset_bearer_env_is_an_error() {
let config = Config::parse(SAMPLE).unwrap();
let err = config
.profile("cratesio")
.unwrap()
.resolve_with(env(&[]))
.unwrap_err();
assert!(err.contains("CRATESIO_TOKEN"), "{err}");
}
#[test]
fn inline_bearer_is_used_when_no_env_indirection() {
let profile: Profile = toml::from_str(
r#"
url = "https://example/mcp"
bearer = "literal"
"#,
)
.unwrap();
assert_eq!(
profile.bearer_token_with(env(&[])).unwrap(),
Some("literal".to_string())
);
}
#[test]
fn transport_is_inferred_from_the_fields() {
let http: Profile = toml::from_str(r#"url = "https://example/mcp""#).unwrap();
assert_eq!(http.transport().unwrap(), Transport::Http);
let stdio: Profile = toml::from_str(r#"command = ["server"]"#).unwrap();
assert_eq!(stdio.transport().unwrap(), Transport::Stdio);
}
#[test]
fn ambiguous_and_empty_profiles_are_errors() {
let both: Profile =
toml::from_str("url = \"https://example/mcp\"\ncommand = [\"server\"]").unwrap();
assert!(both.transport().unwrap_err().contains("both"));
assert!(
Profile::default()
.transport()
.unwrap_err()
.contains("neither")
);
}
#[test]
fn declared_transport_must_have_its_fields() {
let profile: Profile = toml::from_str(r#"transport = "http""#).unwrap();
assert!(profile.resolve_with(env(&[])).unwrap_err().contains("url"));
let profile: Profile = toml::from_str(r#"transport = "stdio""#).unwrap();
assert!(
profile
.resolve_with(env(&[]))
.unwrap_err()
.contains("command")
);
}
#[test]
fn an_unsupported_transport_names_itself() {
let err =
Config::parse("[servers.x]\ntransport = \"ws\"\nurl = \"wss://example\"").unwrap_err();
assert!(err.contains("ws"), "{err}");
}
#[test]
fn aliases_parse_at_both_scopes() {
let config = Config::parse(
r#"
[aliases]
t = "tools"
[servers.cratesio]
url = "https://cratesio-mcp.fly.dev/"
aliases = { dl = "get_downloads crate" }
"#,
)
.unwrap();
assert_eq!(config.aliases.get("t").map(String::as_str), Some("tools"));
assert_eq!(
config.servers["cratesio"]
.aliases
.get("dl")
.map(String::as_str),
Some("get_downloads crate")
);
}
#[test]
fn a_config_without_aliases_parses_to_none_of_them() {
assert!(Config::parse(SAMPLE).unwrap().aliases.is_empty());
}
#[test]
fn a_typo_in_a_profile_key_is_rejected() {
let err =
Config::parse("[servers.x]\nurl = \"https://example\"\nbearrer = \"x\"").unwrap_err();
assert!(err.contains("bearrer"), "{err}");
}
}