use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
use crate::visibility::Visibility;
pub const MCP_DIR_TOKEN: &str = "${MARS_MCP_DIR}";
#[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, PartialEq, Eq)]
pub enum McpTransport {
Stdio {
command: String,
args: Vec<String>,
env: IndexMap<String, EnvRef>,
},
Http {
url: String,
headers: IndexMap<String, HeaderValue>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeaderValue {
Literal(String),
Env {
env: EnvRef,
prefix: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServerDef {
pub name: Option<String>,
pub transport: McpTransport,
pub visibility: Visibility,
pub targets: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedMcpToml {
pub def: McpServerDef,
pub ignored_fields: Vec<&'static str>,
pub unknown_visibility: Option<String>,
}
#[derive(Deserialize)]
struct RawServer {
#[serde(default)]
name: Option<String>,
command: Option<String>,
url: Option<String>,
args: Option<Vec<String>>,
env: Option<IndexMap<String, EnvRef>>,
headers: Option<IndexMap<String, toml::Value>>,
visibility: Option<String>,
#[serde(default)]
targets: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct McpParseError(String);
const HEADER_SHAPE: &str = "expected a string or `{ from = \"env\", var, prefix? }`";
fn parse_header(name: &str, value: toml::Value) -> Result<HeaderValue, McpParseError> {
let fail = |detail: String| McpParseError(format!("header `{name}`: {detail}"));
let mut table = match value {
toml::Value::String(text) => return Ok(HeaderValue::Literal(text)),
toml::Value::Table(table) => table,
_ => return Err(fail(HEADER_SHAPE.to_string())),
};
if let Some(key) = table
.keys()
.find(|key| !matches!(key.as_str(), "from" | "var" | "prefix"))
{
return Err(fail(format!("unknown key `{key}`; {HEADER_SHAPE}")));
}
let prefix = match table.remove("prefix") {
None => String::new(),
Some(toml::Value::String(prefix)) => prefix,
Some(_) => return Err(fail("`prefix` must be a string".to_string())),
};
let env = toml::Value::Table(table)
.try_into::<EnvRef>()
.map_err(|error| fail(format!("{}; {HEADER_SHAPE}", error.message())))?;
Ok(HeaderValue::Env { env, prefix })
}
fn http_strings<'a>(
url: &'a str,
headers: &'a IndexMap<String, HeaderValue>,
) -> impl Iterator<Item = &'a str> {
std::iter::once(url).chain(headers.values().map(|value| match value {
HeaderValue::Literal(text) | HeaderValue::Env { prefix: text, .. } => text.as_str(),
}))
}
pub fn parse_mcp_toml(text: &str) -> Result<ParsedMcpToml, McpParseError> {
let raw: RawServer = toml::from_str(text).map_err(|error| McpParseError(error.to_string()))?;
let fail = |message: &str| Err(McpParseError(message.to_string()));
let mut ignored_fields = Vec::new();
let transport = match (raw.command, raw.url) {
(Some(command), None) => {
if raw.headers.is_some() {
ignored_fields.push("headers");
}
McpTransport::Stdio {
command,
args: raw.args.unwrap_or_default(),
env: raw.env.unwrap_or_default(),
}
}
(None, Some(url)) => {
if raw.args.is_some() {
ignored_fields.push("args");
}
if raw.env.is_some() {
ignored_fields.push("env");
}
let headers = raw
.headers
.unwrap_or_default()
.into_iter()
.map(|(name, value)| -> Result<_, McpParseError> {
Ok((name.clone(), parse_header(&name, value)?))
})
.collect::<Result<IndexMap<_, _>, _>>()?;
if http_strings(&url, &headers).any(|text| text.contains(MCP_DIR_TOKEN)) {
return fail(&format!(
"{MCP_DIR_TOKEN} applies only to a server with `command`; a remote server has no directory"
));
}
McpTransport::Http { url, headers }
}
(Some(_), Some(_)) => {
return fail("set exactly one of `command` or `url`, found both");
}
(None, None) => {
return fail("set exactly one of `command` or `url`, found neither");
}
};
let (visibility, unknown_visibility) = match raw.visibility {
None => (Visibility::Local, None),
Some(text) => match Visibility::parse(&text) {
Some(visibility) => (visibility, None),
None => (Visibility::Local, Some(text)),
},
};
Ok(ParsedMcpToml {
def: McpServerDef {
name: raw.name,
transport,
visibility,
targets: raw.targets,
},
ignored_fields,
unknown_visibility,
})
}
pub fn render_mcp_toml(def: &McpServerDef) -> String {
use toml_edit::{DocumentMut, InlineTable, Value, value};
let env_ref = |var: &str, prefix: &str| {
let mut table = InlineTable::new();
table.insert("from", "env".into());
table.insert("var", var.into());
if !prefix.is_empty() {
table.insert("prefix", prefix.into());
}
Value::InlineTable(table)
};
let mut doc = DocumentMut::new();
if let Some(name) = &def.name {
doc["name"] = value(name.as_str());
}
doc["visibility"] = value(def.visibility.as_str());
if !def.targets.is_empty() {
doc["targets"] = value(
def.targets
.iter()
.map(String::as_str)
.collect::<toml_edit::Array>(),
);
}
match &def.transport {
McpTransport::Stdio { command, args, env } => {
doc["command"] = value(command.as_str());
if !args.is_empty() {
doc["args"] = value(
args.iter()
.map(String::as_str)
.collect::<toml_edit::Array>(),
);
}
if !env.is_empty() {
let mut table = InlineTable::new();
for (name, reference) in env {
table.insert(name, env_ref(reference.var_name(), ""));
}
doc["env"] = value(table);
}
}
McpTransport::Http { url, headers } => {
doc["url"] = value(url.as_str());
if !headers.is_empty() {
let mut table = InlineTable::new();
for (name, header) in headers {
table.insert(
name,
match header {
HeaderValue::Literal(text) => text.as_str().into(),
HeaderValue::Env { env, prefix } => env_ref(env.var_name(), prefix),
},
);
}
doc["headers"] = value(table);
}
}
}
doc.to_string()
}