use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::diagnostic::DiagnosticCollector;
use crate::error::{ConfigError, MarsError};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "from")]
pub enum EnvRef {
#[serde(rename = "env")]
Env {
var: String,
},
}
impl EnvRef {
pub fn var_name(&self) -> &str {
match self {
EnvRef::Env { var } => var.as_str(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct McpServerDef {
#[serde(default)]
pub name: Option<String>,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: indexmap::IndexMap<String, EnvRef>,
#[serde(default = "default_visibility")]
pub visibility: String,
#[serde(default)]
pub targets: Vec<String>,
}
fn default_visibility() -> String {
"local".to_string()
}
#[derive(Debug, Clone)]
pub struct ParsedMcpItem {
pub name: String,
pub def: McpServerDef,
pub source_name: String,
pub decl_order: usize,
}
pub fn discover_mcp_items(
package_root: &Path,
source_name: &str,
decl_order: usize,
) -> Result<Vec<ParsedMcpItem>, MarsError> {
let mcp_dir = package_root.join("mcp");
if !mcp_dir.is_dir() {
return Ok(Vec::new());
}
let mut items = Vec::new();
let mut entries: Vec<_> = std::fs::read_dir(&mcp_dir)
.map_err(MarsError::from)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let dir_name = entry.file_name();
let server_name = dir_name.to_string_lossy();
if server_name.starts_with('.') {
continue;
}
let toml_path = entry.path().join("mcp.toml");
if !toml_path.is_file() {
continue;
}
let raw = std::fs::read_to_string(&toml_path).map_err(MarsError::from)?;
let def: McpServerDef = toml::from_str(&raw).map_err(|e| {
MarsError::Config(ConfigError::Invalid {
message: format!("failed to parse {}: {e}", toml_path.display()),
})
})?;
let resolved_name = def.name.as_deref().unwrap_or(&server_name).to_string();
items.push(ParsedMcpItem {
name: resolved_name,
def,
source_name: source_name.to_string(),
decl_order,
});
}
Ok(items)
}
pub fn check_env_refs(
items: &[ParsedMcpItem],
strict: bool,
diag: &mut DiagnosticCollector,
) -> Result<(), MarsError> {
for item in items {
for (key, env_ref) in &item.def.env {
let var_name = env_ref.var_name();
if std::env::var(var_name).is_err() {
let msg = format!(
"MCP server `{}` (from `{}`): env var `{var_name}` (referenced by `{key}`) \
is not set — the server may fail at runtime",
item.name, item.source_name
);
if strict {
return Err(MarsError::Config(ConfigError::Invalid { message: msg }));
}
diag.warn("mcp-env-missing", msg);
}
}
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct TargetMcpEntry {
pub name: String,
pub command: String,
pub args: Vec<String>,
pub env: indexmap::IndexMap<String, String>,
}
impl TargetMcpEntry {
pub fn from_parsed(item: &ParsedMcpItem) -> Self {
let env = item
.def
.env
.iter()
.map(|(k, v)| (k.clone(), v.var_name().to_string()))
.collect();
Self {
name: item.name.clone(),
command: item.def.command.clone(),
args: item.def.args.clone(),
env,
}
}
}
#[cfg(test)]
pub fn lower_for_target<'a>(items: &'a [ParsedMcpItem], target_root: &str) -> Vec<TargetMcpEntry> {
let mut applicable: Vec<(usize, &'a ParsedMcpItem)> = items
.iter()
.enumerate()
.filter(|item| {
item.1.def.targets.is_empty() || item.1.def.targets.iter().any(|t| t == target_root)
})
.collect();
applicable.sort_by_key(|(original_index, item)| (item.decl_order, *original_index));
applicable
.into_iter()
.map(|(_, item)| TargetMcpEntry::from_parsed(item))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_mcp_toml_dir(dir: &Path, server_name: &str, toml: &str) {
let server_dir = dir.join("mcp").join(server_name);
std::fs::create_dir_all(&server_dir).unwrap();
std::fs::write(server_dir.join("mcp.toml"), toml).unwrap();
}
#[test]
fn discover_finds_mcp_items() {
let tmp = TempDir::new().unwrap();
make_mcp_toml_dir(
tmp.path(),
"context7",
r#"
command = "npx"
args = ["-y", "@upstash/context7-mcp@latest"]
"#,
);
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].name, "context7");
assert_eq!(items[0].def.command, "npx");
assert_eq!(items[0].def.args, &["-y", "@upstash/context7-mcp@latest"]);
}
#[test]
fn discover_empty_when_no_mcp_dir() {
let tmp = TempDir::new().unwrap();
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
assert!(items.is_empty());
}
#[test]
fn discover_skips_dir_without_mcp_toml() {
let tmp = TempDir::new().unwrap();
std::fs::create_dir_all(tmp.path().join("mcp/no-toml")).unwrap();
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
assert!(items.is_empty());
}
#[test]
fn discover_respects_name_override() {
let tmp = TempDir::new().unwrap();
make_mcp_toml_dir(
tmp.path(),
"dir-name",
r#"
name = "custom-name"
command = "node"
"#,
);
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
assert_eq!(items[0].name, "custom-name");
}
#[test]
fn discover_parses_env_refs() {
let tmp = TempDir::new().unwrap();
make_mcp_toml_dir(
tmp.path(),
"api-server",
r#"
command = "npx"
[env]
API_KEY = { from = "env", var = "MY_API_KEY" }
"#,
);
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
assert_eq!(items[0].def.env.len(), 1);
let env_ref = &items[0].def.env["API_KEY"];
assert_eq!(env_ref.var_name(), "MY_API_KEY");
}
#[test]
fn check_env_refs_warns_when_missing() {
let tmp = TempDir::new().unwrap();
make_mcp_toml_dir(
tmp.path(),
"server",
r#"
command = "npx"
[env]
KEY = { from = "env", var = "MARS_TEST_DEFINITELY_NOT_SET_XYZ123" }
"#,
);
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
let mut diag = DiagnosticCollector::new();
check_env_refs(&items, false, &mut diag).unwrap();
let collected = diag.drain();
assert_eq!(collected.len(), 1);
assert!(
collected[0]
.message
.contains("MARS_TEST_DEFINITELY_NOT_SET_XYZ123")
);
}
#[test]
fn check_env_refs_strict_errors_when_missing() {
let tmp = TempDir::new().unwrap();
make_mcp_toml_dir(
tmp.path(),
"server",
r#"
command = "npx"
[env]
KEY = { from = "env", var = "MARS_TEST_DEFINITELY_NOT_SET_XYZ456" }
"#,
);
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
let mut diag = DiagnosticCollector::new();
let result = check_env_refs(&items, true, &mut diag);
assert!(result.is_err());
}
#[test]
fn lower_for_target_filters_by_target() {
let tmp = TempDir::new().unwrap();
make_mcp_toml_dir(
tmp.path(),
"claude-only",
"command = \"npx\"\ntargets = [\".claude\"]",
);
make_mcp_toml_dir(tmp.path(), "all-targets", "command = \"node\"");
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
let claude_entries = lower_for_target(&items, ".claude");
assert_eq!(claude_entries.len(), 2);
let codex_entries = lower_for_target(&items, ".codex");
assert_eq!(codex_entries.len(), 1);
assert_eq!(codex_entries[0].name, "all-targets");
}
#[test]
fn env_ref_preserves_symbolic_var_name() {
let tmp = TempDir::new().unwrap();
make_mcp_toml_dir(
tmp.path(),
"server",
r#"
command = "npx"
[env]
TOKEN = { from = "env", var = "SECRET_TOKEN" }
"#,
);
let items = discover_mcp_items(tmp.path(), "base", 0).unwrap();
let entry = TargetMcpEntry::from_parsed(&items[0]);
assert_eq!(entry.env["TOKEN"], "SECRET_TOKEN");
}
}