use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct McpConfig {
pub server_url: Option<String>,
pub bin_path: String,
pub api_key: Option<String>,
pub allowed_roots: Vec<PathBuf>,
}
impl McpConfig {
pub fn from_env() -> Self {
let server_url = std::env::var("SLOC_SERVER_URL")
.ok()
.filter(|s| !s.is_empty());
let bin_path = std::env::var("SLOC_BIN").unwrap_or_else(|_| "oxide-sloc".to_owned());
let api_key = std::env::var("SLOC_API_KEY").ok().filter(|s| !s.is_empty());
let sep = if cfg!(windows) { ';' } else { ':' };
let allowed_roots = std::env::var("SLOC_MCP_ALLOWED_ROOTS")
.unwrap_or_default()
.split(sep)
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.collect();
Self {
server_url,
bin_path,
api_key,
allowed_roots,
}
}
pub fn server_url(&self) -> anyhow::Result<&str> {
self.server_url
.as_deref()
.ok_or_else(|| anyhow::anyhow!("SLOC_SERVER_URL is not set"))
}
pub fn check_path_allowed(&self, path: &str) -> anyhow::Result<()> {
if self.allowed_roots.is_empty() {
if std::env::var("SLOC_MCP_UNRESTRICTED").as_deref() == Ok("1") {
return Ok(());
}
anyhow::bail!(
"SLOC_MCP_ALLOWED_ROOTS is not configured. \
Set it to a colon-separated list of directories the MCP server may scan, \
e.g. SLOC_MCP_ALLOWED_ROOTS=/home/user/projects. \
To disable path restrictions entirely (trusted local use only), \
set SLOC_MCP_UNRESTRICTED=1."
);
}
let canonical = std::fs::canonicalize(path)
.map_err(|e| anyhow::anyhow!("cannot resolve path {path:?}: {e}"))?;
for root in &self.allowed_roots {
let canonical_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone());
if canonical.starts_with(&canonical_root) {
return Ok(());
}
}
anyhow::bail!(
"path {path:?} is outside the configured SLOC_MCP_ALLOWED_ROOTS: {:?}",
self.allowed_roots
)
}
pub fn resolve_server_url<'a>(&'a self, candidate: Option<&'a str>) -> anyhow::Result<&'a str> {
let configured = self.server_url()?;
match candidate {
None => Ok(configured),
Some(url) if url == configured => Ok(configured),
Some(url) => anyhow::bail!(
"server_url {url:?} does not match configured SLOC_SERVER_URL {configured:?}"
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn cfg_no_roots() -> McpConfig {
McpConfig {
server_url: Some("http://localhost:4317".into()),
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![],
}
}
#[test]
fn from_env_does_not_panic() {
let _ = McpConfig::from_env();
}
#[test]
fn bin_path_defaults_to_oxide_sloc_when_var_unset() {
let bin = std::env::var("SLOC_BIN").unwrap_or_else(|_| "oxide-sloc".to_owned());
assert!(!bin.is_empty());
}
#[test]
fn server_url_ok_when_set() {
let cfg = cfg_no_roots();
assert_eq!(cfg.server_url().unwrap(), "http://localhost:4317");
}
#[test]
fn server_url_errors_when_not_set() {
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![],
};
assert!(cfg.server_url().is_err());
}
#[test]
fn server_url_with_api_key() {
let cfg = McpConfig {
server_url: Some("http://server:4317".into()),
bin_path: "oxide-sloc".into(),
api_key: Some("secret-key".into()),
allowed_roots: vec![],
};
assert_eq!(cfg.api_key.as_deref(), Some("secret-key"));
}
#[test]
fn empty_server_url_filter_logic() {
let raw: Option<String> = Some(String::new());
assert!(raw.as_ref().is_none_or(String::is_empty));
let raw: Option<String> = Some("http://localhost".into());
assert_eq!(
raw.filter(|s| !s.is_empty()).as_deref(),
Some("http://localhost")
);
}
#[test]
fn resolve_server_url_none_candidate_returns_configured() {
let cfg = cfg_no_roots();
assert_eq!(
cfg.resolve_server_url(None).unwrap(),
"http://localhost:4317"
);
}
#[test]
fn resolve_server_url_matching_candidate_accepted() {
let cfg = cfg_no_roots();
assert_eq!(
cfg.resolve_server_url(Some("http://localhost:4317"))
.unwrap(),
"http://localhost:4317"
);
}
#[test]
fn resolve_server_url_non_matching_candidate_rejected() {
let cfg = cfg_no_roots();
assert!(cfg.resolve_server_url(Some("http://evil.com")).is_err());
}
#[test]
fn resolve_server_url_no_server_url_configured_always_errors() {
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![],
};
assert!(cfg.resolve_server_url(None).is_err());
assert!(
cfg.resolve_server_url(Some("http://localhost:4317"))
.is_err()
);
}
#[test]
fn resolve_server_url_empty_string_candidate_rejected() {
let cfg = cfg_no_roots();
assert!(cfg.resolve_server_url(Some("")).is_err());
}
static UNRESTRICTED_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn check_path_allowed_no_roots_fails_closed() {
let _guard = UNRESTRICTED_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cfg = cfg_no_roots();
unsafe { std::env::remove_var("SLOC_MCP_UNRESTRICTED") };
assert!(cfg.check_path_allowed("/arbitrary/path").is_err());
assert!(cfg.check_path_allowed(".").is_err());
}
#[test]
fn check_path_allowed_no_roots_unrestricted_env_passes() {
let _guard = UNRESTRICTED_ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let cfg = cfg_no_roots();
unsafe { std::env::set_var("SLOC_MCP_UNRESTRICTED", "1") };
let result_a = cfg.check_path_allowed("/arbitrary/path");
let result_b = cfg.check_path_allowed(".");
unsafe { std::env::remove_var("SLOC_MCP_UNRESTRICTED") };
assert!(result_a.is_ok());
assert!(result_b.is_ok());
}
#[test]
fn check_path_allowed_current_dir_inside_root() {
let current = std::env::current_dir().expect("cwd must be readable");
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![current],
};
assert!(cfg.check_path_allowed(".").is_ok());
}
#[test]
fn check_path_allowed_path_outside_root_rejected() {
let tmp = std::env::temp_dir();
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![tmp],
};
let result = cfg.check_path_allowed(".");
let _ = result;
}
#[test]
fn check_path_allowed_nonexistent_path_errors() {
let tmp = std::env::temp_dir();
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![tmp],
};
let result = cfg.check_path_allowed("/nonexistent/__sloc_test_path__/x/y/z");
assert!(
result.is_err(),
"nonexistent path must fail check_path_allowed"
);
}
#[test]
fn check_path_allowed_multiple_roots_one_matches() {
let tmp = std::env::temp_dir();
let cwd = std::env::current_dir().expect("cwd must be readable");
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![tmp, cwd],
};
assert!(cfg.check_path_allowed(".").is_ok());
}
#[test]
fn allowed_roots_empty_when_env_var_unset() {
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![],
};
assert!(cfg.allowed_roots.is_empty());
}
#[test]
fn allowed_roots_parsed_from_vec() {
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![PathBuf::from("/tmp"), PathBuf::from("/home/user")],
};
assert_eq!(cfg.allowed_roots.len(), 2);
assert_eq!(cfg.allowed_roots[0], PathBuf::from("/tmp"));
}
#[test]
fn check_path_allowed_skips_uncanonicalizable_root_then_matches_valid_root() {
let cwd = std::env::current_dir().expect("cwd must be readable");
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![PathBuf::from("/nonexistent/__uncanon_root__/x"), cwd],
};
assert!(cfg.check_path_allowed(".").is_ok());
}
}