Skip to main content

mars_schema/
mcp_server.rs

1//! The authored MCP server definition: the vocabulary and the pure parse of one
2//! `mcp/<name>/mcp.toml`.
3//!
4//! [`crate::mcp`] is the other MCP module here and is unrelated: it parses the
5//! `mcp(server/tool)` references in tool lists. This one owns what a server
6//! *is*. Discovery, `${MARS_MCP_DIR}` substitution, env preflight and target
7//! rendering stay in the binary, which consumes these types.
8
9use indexmap::IndexMap;
10use serde::{Deserialize, Serialize};
11
12use crate::visibility::Visibility;
13
14/// The authored placeholder for the directory a stdio server's files are
15/// installed in. A remote server has no such directory, so [`parse_mcp_toml`]
16/// rejects it there.
17pub const MCP_DIR_TOKEN: &str = "${MARS_MCP_DIR}";
18
19/// A symbolic environment reference.
20///
21/// `from = "env"` is the only supported kind. The value is never resolved: it
22/// flows through as a reference for the harness to read at runtime.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(tag = "from")]
25pub enum EnvRef {
26    /// Read from the process environment at the harness's runtime.
27    #[serde(rename = "env")]
28    Env {
29        /// Name of the environment variable.
30        var: String,
31    },
32}
33
34impl EnvRef {
35    /// The environment variable name, for rendering and preflight checks.
36    pub fn var_name(&self) -> &str {
37        match self {
38            EnvRef::Env { var } => var.as_str(),
39        }
40    }
41}
42
43/// How a server is reached.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum McpTransport {
46    /// A local process the harness launches and talks to over stdio.
47    Stdio {
48        /// Command to launch the MCP server.
49        command: String,
50        /// Arguments to pass to the command.
51        args: Vec<String>,
52        /// Symbolic environment references, by the variable name the server reads.
53        env: IndexMap<String, EnvRef>,
54    },
55    /// A remote server over Streamable HTTP. There is no SSE: no harness Mars
56    /// targets takes it uniformly, so the vocabulary cannot ask for it.
57    Http {
58        url: String,
59        /// Request headers, by header name.
60        headers: IndexMap<String, HeaderValue>,
61    },
62}
63
64/// A request header's value.
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub enum HeaderValue {
67    Literal(String),
68    /// `prefix` followed by the value of the referenced environment variable,
69    /// read by the harness at runtime. The prefix is literal and may be empty.
70    Env {
71        env: EnvRef,
72        prefix: String,
73    },
74}
75
76/// Parsed content of a single `mcp/<name>/mcp.toml`.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct McpServerDef {
79    /// Overrides the server's directory name as its config key.
80    pub name: Option<String>,
81    pub transport: McpTransport,
82    pub visibility: Visibility,
83    /// Optional target filter — if empty, applies to all targets.
84    pub targets: Vec<String>,
85}
86
87/// A parsed `mcp.toml` and the fields it set that its transport does not use.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct ParsedMcpToml {
90    pub def: McpServerDef,
91    /// `headers` on a `command` server; `args` or `env` on a `url` server.
92    /// Ignored, like an unknown key, so a stray field cannot drop a server.
93    pub ignored_fields: Vec<&'static str>,
94    /// A `visibility` that is neither `local` nor `exported`, which [`McpServerDef::visibility`]
95    /// reads as `local`. The caller says so.
96    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/// Why an `mcp.toml` did not parse. The caller prefixes the file it came from.
114#[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
153/// Parse the text of one `mcp.toml`. A server sets exactly one of `command`
154/// (stdio) or `url` (remote).
155pub 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
218/// The `mcp.toml` text for `def`: what [`parse_mcp_toml`] reads back as `def`. The one renderer,
219/// for the import's staged files and the bytes `normalize` reports.
220pub 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}