1use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11
12use crate::visibility::Visibility;
13
14pub const MCP_DIR_TOKEN: &str = "${MARS_MCP_DIR}";
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "from")]
25pub enum EnvRef {
26 #[serde(rename = "env")]
28 Env {
29 var: String,
31 },
32}
33
34impl EnvRef {
35 pub fn var_name(&self) -> &str {
37 match self {
38 EnvRef::Env { var } => var.as_str(),
39 }
40 }
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum McpTransport {
46 Stdio {
48 command: String,
50 args: Vec<String>,
52 env: IndexMap<String, EnvRef>,
54 },
55 Http {
58 url: String,
59 headers: IndexMap<String, HeaderValue>,
61 },
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum HeaderValue {
67 Literal(String),
68 Env {
71 env: EnvRef,
72 prefix: String,
73 },
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct McpServerDef {
79 pub name: Option<String>,
81 pub transport: McpTransport,
82 pub visibility: Visibility,
83 pub targets: Vec<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ParsedMcpToml {
90 pub def: McpServerDef,
91 pub ignored_fields: Vec<&'static str>,
94 pub unknown_visibility: Option<String>,
97}
98
99#[derive(Deserialize)]
100struct RawServer {
101 #[serde(default)]
102 name: Option<String>,
103 command: Option<String>,
104 url: Option<String>,
105 args: Option<Vec<String>>,
106 env: Option<IndexMap<String, EnvRef>>,
107 headers: Option<IndexMap<String, toml::Value>>,
108 visibility: Option<String>,
109 #[serde(default)]
110 targets: Vec<String>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
115#[error("{0}")]
116pub struct McpParseError(String);
117
118const HEADER_SHAPE: &str = "expected a string or `{ from = \"env\", var, prefix? }`";
119
120fn parse_header(name: &str, value: toml::Value) -> Result<HeaderValue, McpParseError> {
121 let fail = |detail: String| McpParseError(format!("header `{name}`: {detail}"));
122 let mut table = match value {
123 toml::Value::String(text) => return Ok(HeaderValue::Literal(text)),
124 toml::Value::Table(table) => table,
125 _ => return Err(fail(HEADER_SHAPE.to_string())),
126 };
127 if let Some(key) = table
128 .keys()
129 .find(|key| !matches!(key.as_str(), "from" | "var" | "prefix"))
130 {
131 return Err(fail(format!("unknown key `{key}`; {HEADER_SHAPE}")));
132 }
133 let prefix = match table.remove("prefix") {
134 None => String::new(),
135 Some(toml::Value::String(prefix)) => prefix,
136 Some(_) => return Err(fail("`prefix` must be a string".to_string())),
137 };
138 let env = toml::Value::Table(table)
139 .try_into::<EnvRef>()
140 .map_err(|error| fail(format!("{}; {HEADER_SHAPE}", error.message())))?;
141 Ok(HeaderValue::Env { env, prefix })
142}
143
144fn http_strings<'a>(
145 url: &'a str,
146 headers: &'a IndexMap<String, HeaderValue>,
147) -> impl Iterator<Item = &'a str> {
148 std::iter::once(url).chain(headers.values().map(|value| match value {
149 HeaderValue::Literal(text) | HeaderValue::Env { prefix: text, .. } => text.as_str(),
150 }))
151}
152
153pub fn parse_mcp_toml(text: &str) -> Result<ParsedMcpToml, McpParseError> {
156 let raw: RawServer = toml::from_str(text).map_err(|error| McpParseError(error.to_string()))?;
157 let fail = |message: &str| Err(McpParseError(message.to_string()));
158 let mut ignored_fields = Vec::new();
159 let transport = match (raw.command, raw.url) {
160 (Some(command), None) => {
161 if raw.headers.is_some() {
162 ignored_fields.push("headers");
163 }
164 McpTransport::Stdio {
165 command,
166 args: raw.args.unwrap_or_default(),
167 env: raw.env.unwrap_or_default(),
168 }
169 }
170 (None, Some(url)) => {
171 if raw.args.is_some() {
172 ignored_fields.push("args");
173 }
174 if raw.env.is_some() {
175 ignored_fields.push("env");
176 }
177 let headers = raw
178 .headers
179 .unwrap_or_default()
180 .into_iter()
181 .map(|(name, value)| -> Result<_, McpParseError> {
182 Ok((name.clone(), parse_header(&name, value)?))
183 })
184 .collect::<Result<IndexMap<_, _>, _>>()?;
185 if http_strings(&url, &headers).any(|text| text.contains(MCP_DIR_TOKEN)) {
186 return fail(&format!(
187 "{MCP_DIR_TOKEN} applies only to a server with `command`; a remote server has no directory"
188 ));
189 }
190 McpTransport::Http { url, headers }
191 }
192 (Some(_), Some(_)) => {
193 return fail("set exactly one of `command` or `url`, found both");
194 }
195 (None, None) => {
196 return fail("set exactly one of `command` or `url`, found neither");
197 }
198 };
199 let (visibility, unknown_visibility) = match raw.visibility {
200 None => (Visibility::Local, None),
201 Some(text) => match Visibility::parse(&text) {
202 Some(visibility) => (visibility, None),
203 None => (Visibility::Local, Some(text)),
204 },
205 };
206 Ok(ParsedMcpToml {
207 def: McpServerDef {
208 name: raw.name,
209 transport,
210 visibility,
211 targets: raw.targets,
212 },
213 ignored_fields,
214 unknown_visibility,
215 })
216}
217
218pub fn render_mcp_toml(def: &McpServerDef) -> String {
221 use toml_edit::{DocumentMut, InlineTable, Value, value};
222
223 let env_ref = |var: &str, prefix: &str| {
224 let mut table = InlineTable::new();
225 table.insert("from", "env".into());
226 table.insert("var", var.into());
227 if !prefix.is_empty() {
228 table.insert("prefix", prefix.into());
229 }
230 Value::InlineTable(table)
231 };
232 let mut doc = DocumentMut::new();
233 if let Some(name) = &def.name {
234 doc["name"] = value(name.as_str());
235 }
236 doc["visibility"] = value(def.visibility.as_str());
237 if !def.targets.is_empty() {
238 doc["targets"] = value(
239 def.targets
240 .iter()
241 .map(String::as_str)
242 .collect::<toml_edit::Array>(),
243 );
244 }
245 match &def.transport {
246 McpTransport::Stdio { command, args, env } => {
247 doc["command"] = value(command.as_str());
248 if !args.is_empty() {
249 doc["args"] = value(
250 args.iter()
251 .map(String::as_str)
252 .collect::<toml_edit::Array>(),
253 );
254 }
255 if !env.is_empty() {
256 let mut table = InlineTable::new();
257 for (name, reference) in env {
258 table.insert(name, env_ref(reference.var_name(), ""));
259 }
260 doc["env"] = value(table);
261 }
262 }
263 McpTransport::Http { url, headers } => {
264 doc["url"] = value(url.as_str());
265 if !headers.is_empty() {
266 let mut table = InlineTable::new();
267 for (name, header) in headers {
268 table.insert(
269 name,
270 match header {
271 HeaderValue::Literal(text) => text.as_str().into(),
272 HeaderValue::Env { env, prefix } => env_ref(env.var_name(), prefix),
273 },
274 );
275 }
276 doc["headers"] = value(table);
277 }
278 }
279 }
280 doc.to_string()
281}