use std::{
collections::{BTreeMap, HashMap},
path::Path,
};
use mentra::{McpServerConfig, McpSseServerConfig};
use serde::Deserialize;
use crate::expand::expand;
use super::{McpError, McpServer};
#[derive(Debug, Deserialize)]
struct McpFile {
#[serde(rename = "mcpServers")]
mcp_servers: Option<BTreeMap<String, RawServer>>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawServer {
#[serde(rename = "type")]
transport: Option<String>,
command: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
cwd: Option<String>,
url: Option<String>,
#[serde(default)]
headers: BTreeMap<String, String>,
}
enum Transport {
Stdio,
Sse,
}
pub(super) fn parse(path: &Path, text: &str) -> Result<Vec<McpServer>, McpError> {
parse_with(path, text, &|name| std::env::var(name).ok())
}
fn parse_with(
path: &Path,
text: &str,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<Vec<McpServer>, McpError> {
let file: McpFile = serde_json::from_str(text).map_err(|source| McpError::Parse {
path: path.to_path_buf(),
problem: match source.classify() {
serde_json::error::Category::Syntax => "a syntax error",
serde_json::error::Category::Data => "a value of the wrong type",
serde_json::error::Category::Eof => "an unexpected end of input",
serde_json::error::Category::Io => "a read error",
},
line: source.line(),
column: source.column(),
})?;
let Some(entries) = file.mcp_servers else {
return Err(McpError::NoServers {
path: path.to_path_buf(),
});
};
let origin = path.display().to_string();
entries
.into_iter()
.map(|(name, raw)| raw.into_server(&origin, name, lookup))
.collect()
}
impl RawServer {
fn into_server(
self,
origin: &str,
name: String,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<McpServer, McpError> {
let invalid = |reason: String| McpError::Invalid {
origin: origin.to_string(),
name: name.clone(),
reason,
};
let expanded = |field: &str, raw: &str| -> Result<String, McpError> {
expand(raw, lookup)
.map_err(|reason| invalid(format!("has a `{field}` value that {reason}")))
};
if name.trim().is_empty() {
return Err(invalid("has an empty name".to_string()));
}
match self.transport(origin, &name)? {
Transport::Stdio => {
let command = self
.command
.as_deref()
.filter(|command| !command.trim().is_empty())
.ok_or_else(|| invalid("has no `command` to run".to_string()))?;
Ok(McpServer::Stdio(McpServerConfig {
command: expanded("command", command)?,
args: self
.args
.iter()
.enumerate()
.map(|(index, arg)| expanded(&format!("args[{index}]"), arg))
.collect::<Result<_, _>>()?,
env: self
.env
.iter()
.map(|(key, value)| {
Ok((key.clone(), expanded(&format!("env.{key}"), value)?))
})
.collect::<Result<_, McpError>>()?,
cwd: self
.cwd
.as_deref()
.map(|cwd| expanded("cwd", cwd))
.transpose()?,
name,
}))
}
Transport::Sse => {
let url = self
.url
.as_deref()
.filter(|url| !url.trim().is_empty())
.ok_or_else(|| invalid("has no `url` to reach".to_string()))?;
let config = McpSseServerConfig::new(name.clone(), expanded("url", url)?);
self.headers
.iter()
.try_fold(config, |config, (key, value)| {
Ok(config
.with_header(key.clone(), expanded(&format!("headers.{key}"), value)?))
})
.map(McpServer::Sse)
}
}
}
fn transport(&self, origin: &str, name: &str) -> Result<Transport, McpError> {
let unsupported = |transport: &str| McpError::UnsupportedTransport {
origin: origin.to_string(),
name: name.to_string(),
transport: transport.to_string(),
};
let invalid = |reason: String| McpError::Invalid {
origin: origin.to_string(),
name: name.to_string(),
reason,
};
match self.transport.as_deref().map(str::trim) {
Some("stdio") => Ok(Transport::Stdio),
Some("sse") => Ok(Transport::Sse),
Some("http") | Some("streamable-http") => Err(unsupported("Streamable HTTP")),
Some(other) => Err(invalid(format!("names an unknown transport `{other}`"))),
None => match (self.command.is_some(), self.url.is_some()) {
(true, false) => Ok(Transport::Stdio),
(false, true) => Ok(Transport::Sse),
(true, true) => Err(invalid(
"has both `command` and `url`; set `type` to say which is meant".to_string(),
)),
(false, false) => Err(invalid("has neither `command` nor `url`".to_string())),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn nothing_set(_: &str) -> Option<String> {
None
}
fn parse_text(text: &str) -> Result<Vec<McpServer>, McpError> {
parse_with(Path::new("/repo/.mcp.json"), text, ¬hing_set)
}
#[test]
fn the_documented_shape_becomes_a_stdio_server() {
let servers = parse_text(
r#"{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"env": {"ROOT": "/repo"}
}
}
}"#,
)
.expect("a well-formed file");
assert_eq!(servers.len(), 1);
let config = servers[0].as_stdio().expect("stdio");
assert_eq!(config.name, "filesystem", "the map key becomes the name");
assert_eq!(config.command, "npx");
assert_eq!(
config.args,
vec!["-y", "@modelcontextprotocol/server-filesystem"]
);
assert_eq!(config.env.get("ROOT").map(String::as_str), Some("/repo"));
assert_eq!(config.cwd, None);
}
#[test]
fn an_empty_server_object_is_allowed() {
let servers = parse_text(r#"{"mcpServers": {}}"#).expect("explicitly empty is a choice");
assert!(servers.is_empty());
}
#[test]
fn a_missing_mcp_servers_key_is_an_error() {
let error = parse_text(r#"{"mcpservers": {}}"#).expect_err("a misspelled key is caught");
assert!(matches!(error, McpError::NoServers { .. }), "{error}");
}
#[test]
fn unknown_keys_are_tolerated() {
let servers = parse_text(
r#"{
"$schema": "https://example.com/mcp.json",
"mcpServers": {"fs": {"command": "npx", "disabled": false}}
}"#,
)
.expect("these files are shared with other agents");
assert_eq!(servers.len(), 1);
}
#[test]
fn cwd_is_carried_through() {
let servers = parse_text(r#"{"mcpServers":{"fs":{"command":"srv","cwd":"/tmp"}}}"#)
.expect("a well-formed file");
assert_eq!(
servers[0].as_stdio().expect("stdio").cwd.as_deref(),
Some("/tmp")
);
}
#[test]
fn a_url_without_a_type_is_an_sse_server() {
let servers = parse_text(r#"{"mcpServers":{"obs":{"url":"https://example.com/sse"}}}"#)
.expect("shape names the transport");
let config = servers[0].as_sse().expect("sse");
assert_eq!(config.name, "obs");
assert_eq!(config.url, "https://example.com/sse");
}
#[test]
fn an_explicit_sse_type_is_honored() {
let servers =
parse_text(r#"{"mcpServers":{"obs":{"type":"sse","url":"https://example.com/sse"}}}"#)
.expect("an explicit type");
assert!(servers[0].as_sse().is_some());
}
#[test]
fn sse_headers_are_carried_through() {
let servers = parse_text(
r#"{"mcpServers":{"obs":{"url":"https://example.com/sse","headers":{"authorization":"Bearer t"}}}}"#,
)
.expect("a well-formed file");
let config = servers[0].as_sse().expect("sse");
assert_eq!(
config
.headers
.get("authorization")
.map(mentra::mcp::SecretString::expose_secret),
Some("Bearer t")
);
}
#[test]
fn streamable_http_is_refused_by_name() {
let error = parse_text(r#"{"mcpServers":{"api":{"type":"http","url":"https://x/mcp"}}}"#)
.expect_err("basis cannot serve it");
assert!(
matches!(error, McpError::UnsupportedTransport { .. }),
"a client must learn its server will not start: {error}"
);
}
#[test]
fn an_unknown_transport_is_an_error() {
let error = parse_text(r#"{"mcpServers":{"x":{"type":"carrier-pigeon","url":"u"}}}"#)
.expect_err("unknown transports are errors");
assert!(matches!(error, McpError::Invalid { .. }), "{error}");
}
#[test]
fn an_entry_with_neither_command_nor_url_is_an_error() {
let error = parse_text(r#"{"mcpServers":{"x":{"args":["-y"]}}}"#)
.expect_err("nothing to connect to");
assert!(matches!(error, McpError::Invalid { .. }), "{error}");
}
#[test]
fn an_entry_with_both_command_and_url_is_an_error() {
let error = parse_text(r#"{"mcpServers":{"x":{"command":"srv","url":"https://x"}}}"#)
.expect_err("ambiguous rather than guessed at");
assert!(matches!(error, McpError::Invalid { .. }), "{error}");
}
#[test]
fn an_empty_command_is_an_error() {
let error = parse_text(r#"{"mcpServers":{"x":{"type":"stdio","command":" "}}}"#)
.expect_err("nothing to spawn");
assert!(matches!(error, McpError::Invalid { .. }), "{error}");
}
#[test]
fn an_empty_name_is_an_error() {
let error = parse_text(r#"{"mcpServers":{"":{"command":"srv"}}}"#)
.expect_err("the name namespaces the tools");
assert!(matches!(error, McpError::Invalid { .. }), "{error}");
}
#[test]
fn environment_placeholders_are_expanded() {
let servers = parse_with(
Path::new("/repo/.mcp.json"),
r#"{"mcpServers":{"gh":{"command":"srv","args":["--org","${ORG}"],"env":{"TOKEN":"${GH_TOKEN}"}}}}"#,
&|name| match name {
"ORG" => Some("oops-rs".to_string()),
"GH_TOKEN" => Some("secret".to_string()),
_ => None,
},
)
.expect("both are set");
let config = servers[0].as_stdio().expect("stdio");
assert_eq!(config.args, vec!["--org", "oops-rs"]);
assert_eq!(config.env.get("TOKEN").map(String::as_str), Some("secret"));
}
#[test]
fn an_unset_placeholder_names_the_server_and_the_field() {
let error =
parse_text(r#"{"mcpServers":{"gh":{"command":"srv","env":{"T":"${GH_TOKEN}"}}}}"#)
.expect_err("an unset variable is an error");
let rendered = error.to_string();
assert!(rendered.contains("gh"), "{rendered}");
assert!(rendered.contains("env.T"), "{rendered}");
assert!(rendered.contains("GH_TOKEN"), "{rendered}");
}
#[test]
fn no_error_repeats_a_value_from_the_file() {
const SECRET: &str = "sk-live-do-not-print-me";
let broken = [
format!(r#"{{"mcpServers":{{"a":{{"command":"srv","env":{{"T":"{SECRET}${{"}}}}}}}}"#),
format!(r#"{{"mcpServers":{{"b":{{"command":"srv","args":["{SECRET}${{NOPE}}"]}}}}}}"#),
format!(
r#"{{"mcpServers":{{"c":{{"url":"https://x/sse","headers":{{"authorization":"{SECRET}${{}}"}}}}}}}}"#
),
format!(r#"{{"mcpServers":{{"d":{{"command":"srv","env":"{SECRET}"}}}}}}"#),
];
for text in broken {
let error = parse_text(&text).expect_err("each of these fails");
assert!(
!error.to_string().contains(SECRET),
"a value leaked into: {error}"
);
}
}
#[test]
fn servers_come_back_in_a_stable_order() {
let servers = parse_text(
r#"{"mcpServers":{"zeta":{"command":"z"},"alpha":{"command":"a"},"mid":{"command":"m"}}}"#,
)
.expect("a well-formed file");
let names: Vec<&str> = servers.iter().map(McpServer::name).collect();
assert_eq!(
names,
vec!["alpha", "mid", "zeta"],
"registration order must not depend on hashing"
);
}
}