use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::ConnectorError;
pub const CONNECTORS_FILE: &str = "connectors.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectorConfig {
pub slug: String,
pub name: String,
#[serde(default)]
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stdio: Option<StdioConfig>,
#[serde(default)]
pub secret_headers: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth: Option<OAuthConfig>,
#[serde(default)]
pub enabled_tools: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StdioConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
pub token_endpoint: String,
pub client_id: String,
#[serde(default)]
pub has_client_secret: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource: Option<String>,
#[serde(default)]
pub scopes: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConnectorsFile {
#[serde(default)]
pub connectors: Vec<ConnectorConfig>,
}
pub fn connectors_path() -> Result<PathBuf, ConnectorError> {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.ok_or_else(|| ConnectorError::Io("cannot determine home directory".into()))?;
Ok(PathBuf::from(home).join(".car").join(CONNECTORS_FILE))
}
pub fn load_from(path: &Path) -> Result<ConnectorsFile, ConnectorError> {
match std::fs::read_to_string(path) {
Ok(s) => serde_json::from_str(&s)
.map_err(|e| ConnectorError::Io(format!("parse {}: {e}", path.display()))),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(ConnectorsFile::default()),
Err(e) => Err(ConnectorError::Io(format!("read {}: {e}", path.display()))),
}
}
pub fn save_to(path: &Path, file: &ConnectorsFile) -> Result<(), ConnectorError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ConnectorError::Io(format!("create {}: {e}", parent.display())))?;
}
let s = serde_json::to_string_pretty(file).map_err(|e| ConnectorError::Io(e.to_string()))?;
std::fs::write(path, s).map_err(|e| ConnectorError::Io(format!("write {}: {e}", path.display())))
}
#[derive(Debug, Clone, Deserialize)]
struct TeamConnector {
name: String,
#[serde(default)]
url: Option<String>,
#[serde(default)]
command: Option<String>,
#[serde(default)]
args: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct TeamConnectorsFile {
#[serde(default)]
connector: Vec<TeamConnector>,
}
pub fn team_connectors_path() -> Option<PathBuf> {
let mut dir = std::env::current_dir().ok()?;
loop {
let candidate = dir.join(".car").join("connectors.toml");
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
return None;
}
}
}
pub fn load_team_connectors() -> Vec<ConnectorConfig> {
let Some(path) = team_connectors_path() else {
return Vec::new();
};
let Ok(text) = std::fs::read_to_string(&path) else {
return Vec::new();
};
match parse_team_connectors(&text) {
Ok(configs) => configs,
Err(e) => {
tracing::warn!("ignoring malformed {}: {e}", path.display());
Vec::new()
}
}
}
fn parse_team_connectors(text: &str) -> Result<Vec<ConnectorConfig>, toml::de::Error> {
let parsed: TeamConnectorsFile = toml::from_str(text)?;
Ok(parsed
.connector
.into_iter()
.filter_map(|c| {
let slug = crate::schema::slugify(&c.name);
if let Some(command) = c.command {
Some(ConnectorConfig {
slug,
name: c.name,
url: String::new(),
stdio: Some(StdioConfig {
command,
args: c.args,
env: std::collections::BTreeMap::new(),
}),
secret_headers: Vec::new(),
oauth: None,
enabled_tools: Vec::new(),
})
} else {
c.url.map(|url| ConnectorConfig {
slug,
name: c.name,
url,
stdio: None,
secret_headers: Vec::new(),
oauth: None,
enabled_tools: Vec::new(),
})
}
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_loads_empty() {
let path = std::env::temp_dir().join("car-connectors-does-not-exist-xyz.json");
let _ = std::fs::remove_file(&path);
let file = load_from(&path).unwrap();
assert!(file.connectors.is_empty());
}
#[test]
fn parses_team_connectors_http_and_stdio() {
let toml_text = r#"
[[connector]]
name = "Acme Docs"
url = "https://mcp.acme.dev/docs"
[[connector]]
name = "Local FS"
command = "npx"
args = ["-y", "@mcp/server-filesystem", "/srv"]
[[connector]]
name = "Bad — no transport"
"#;
let configs = parse_team_connectors(toml_text).unwrap();
assert_eq!(configs.len(), 2);
let acme = &configs[0];
assert_eq!(acme.slug, "acme_docs");
assert_eq!(acme.url, "https://mcp.acme.dev/docs");
assert!(acme.stdio.is_none());
let fs = &configs[1];
assert_eq!(fs.slug, "local_fs");
assert!(fs.url.is_empty());
let stdio = fs.stdio.as_ref().unwrap();
assert_eq!(stdio.command, "npx");
assert_eq!(stdio.args, ["-y", "@mcp/server-filesystem", "/srv"]);
}
#[test]
fn roundtrip_preserves_config() {
let dir = std::env::temp_dir().join("car-connectors-test-roundtrip");
let path = dir.join(CONNECTORS_FILE);
let _ = std::fs::remove_dir_all(&dir);
let file = ConnectorsFile {
connectors: vec![ConnectorConfig {
slug: "github".into(),
name: "GitHub".into(),
url: "https://mcp.example/github".into(),
stdio: None,
secret_headers: vec!["Authorization".into()],
oauth: None,
enabled_tools: vec!["create_issue".into()],
}],
};
save_to(&path, &file).unwrap();
let loaded = load_from(&path).unwrap();
assert_eq!(loaded.connectors.len(), 1);
assert_eq!(loaded.connectors[0].slug, "github");
assert_eq!(loaded.connectors[0].secret_headers, vec!["Authorization"]);
assert_eq!(loaded.connectors[0].enabled_tools, vec!["create_issue"]);
let _ = std::fs::remove_dir_all(&dir);
}
}