pub(crate) mod connections;
mod file;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::context::ContextScope;
pub use mentra::mcp::SecretString;
pub use mentra::{
McpServerConfig, McpSseLimits, McpSseServerConfig, McpStreamableHttpConfigError,
McpStreamableHttpLimits, McpStreamableHttpServerConfig,
};
pub const DEFAULT_WORKSPACE_MCP_FILE: &str = ".mcp.json";
pub const DEFAULT_GLOBAL_MCP_FILE: &str = "mcp.json";
#[derive(Clone)]
#[non_exhaustive]
pub enum McpServer {
Stdio(McpServerConfig),
Sse(McpSseServerConfig),
Http(McpStreamableHttpServerConfig),
}
impl std::fmt::Debug for McpServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stdio(config) => f
.debug_struct("Stdio")
.field("name", &config.name)
.field("command", &config.command)
.field("args", &config.args)
.field("cwd", &config.cwd)
.field("env", &crate::redaction::redacted_env(config.env.keys()))
.finish(),
Self::Sse(config) => f
.debug_struct("Sse")
.field("name", &config.name)
.field("url", &redacted_url(&config.url))
.field("headers", &config.headers)
.finish(),
Self::Http(config) => f
.debug_struct("Http")
.field("name", &config.name)
.field("url", &redacted_url(&config.url))
.field("headers", &config.headers)
.finish(),
}
}
}
fn redacted_url(url: &str) -> String {
let (base, query) = match url.split_once('?') {
Some((base, _)) => (base, "?[redacted]"),
None => (url, ""),
};
let base = match base.split_once("://") {
Some((scheme, rest)) => {
let authority_end = rest.find('/').unwrap_or(rest.len());
match rest[..authority_end].rfind('@') {
Some(at) => format!("{scheme}://[redacted]@{}", &rest[at + 1..]),
None => base.to_string(),
}
}
None => base.to_string(),
};
format!("{base}{query}")
}
impl McpServer {
pub fn name(&self) -> &str {
match self {
Self::Stdio(config) => &config.name,
Self::Sse(config) => &config.name,
Self::Http(config) => &config.name,
}
}
pub fn as_stdio(&self) -> Option<&McpServerConfig> {
match self {
Self::Stdio(config) => Some(config),
Self::Sse(_) | Self::Http(_) => None,
}
}
pub fn as_sse(&self) -> Option<&McpSseServerConfig> {
match self {
Self::Sse(config) => Some(config),
Self::Stdio(_) | Self::Http(_) => None,
}
}
pub fn as_http(&self) -> Option<&McpStreamableHttpServerConfig> {
match self {
Self::Http(config) => Some(config),
Self::Stdio(_) | Self::Sse(_) => None,
}
}
pub fn validate_name(name: &str) -> Result<(), &'static str> {
if name.contains("__") {
Err(
"has a name containing `__`, which mentra's `mcp__{server}__{tool}` \
tool-name encoding uses as the separator between server and tool",
)
} else if name.ends_with('_') {
Err(
"has a name ending in `_`, which would join mentra's `__` separator \
into an earlier boundary — server `evil_` with tool `_thing` encodes \
to the same `mcp__evil____thing` as server `evil` with tool `__thing`",
)
} else {
Ok(())
}
}
}
#[derive(Debug, Clone)]
pub struct McpConfig {
pub workspace_file: PathBuf,
pub global_dir: Option<PathBuf>,
pub supplied: Vec<McpServer>,
}
impl Default for McpConfig {
fn default() -> Self {
Self {
workspace_file: PathBuf::from(DEFAULT_WORKSPACE_MCP_FILE),
global_dir: crate::context::default_global_dir(),
supplied: Vec::new(),
}
}
}
impl McpConfig {
pub fn with_supplied(self, supplied: Vec<McpServer>) -> Self {
Self { supplied, ..self }
}
pub fn supplied_only(self) -> Self {
Self {
workspace_file: PathBuf::new(),
global_dir: None,
..self
}
}
}
#[derive(Debug, Clone)]
pub struct McpSource {
pub path: PathBuf,
pub scope: ContextScope,
pub servers: Vec<McpServer>,
}
pub(crate) struct ConfiguredServer {
pub(crate) server: McpServer,
pub(crate) sse_inferred: bool,
}
struct ReadSource {
path: PathBuf,
scope: ContextScope,
configured: Vec<ConfiguredServer>,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum McpError {
#[error("failed to read MCP configuration {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("{path} is not valid JSON: {problem} at line {line}, column {column}")]
Parse {
path: PathBuf,
problem: &'static str,
line: usize,
column: usize,
},
#[error("{path} has no `mcpServers` object")]
NoServers { path: PathBuf },
#[error("{origin}: MCP server `{name}` {reason}")]
Invalid {
origin: String,
name: String,
reason: String,
},
#[error("{origin}: an MCP server was configured over a transport basis does not recognize")]
UnknownTransport { origin: String },
}
pub fn discover(workspace: &Path, config: &McpConfig) -> Result<Vec<McpSource>, McpError> {
Ok(discovered(workspace, config)?
.into_iter()
.map(|source| McpSource {
path: source.path,
scope: source.scope,
servers: source
.configured
.into_iter()
.map(|configured| configured.server)
.collect(),
})
.collect())
}
fn discovered(workspace: &Path, config: &McpConfig) -> Result<Vec<ReadSource>, McpError> {
let mut sources = Vec::new();
if let Some(workspace_file) = crate::paths::candidate(workspace, &config.workspace_file)
&& workspace_file.is_file()
{
sources.push(read(workspace_file, ContextScope::Workspace)?);
}
if let Some(global) = &config.global_dir {
let global_file = global.join(DEFAULT_GLOBAL_MCP_FILE);
if global_file.is_file()
&& !sources
.iter()
.any(|source| crate::paths::same_dir(&source.path, &global_file))
{
sources.push(read(global_file, ContextScope::Global)?);
}
}
Ok(sources)
}
pub fn servers(workspace: &Path, config: &McpConfig) -> Result<Vec<McpServer>, McpError> {
Ok(configured(workspace, config)?
.into_iter()
.map(|configured| configured.server)
.collect())
}
pub(crate) fn configured(
workspace: &Path,
config: &McpConfig,
) -> Result<Vec<ConfiguredServer>, McpError> {
let discovered = discovered(workspace, config)?;
Ok(layer(configured_supplied(config)?.into_iter().chain(
discovered.into_iter().flat_map(|source| source.configured),
)))
}
pub(crate) fn configured_supplied(config: &McpConfig) -> Result<Vec<ConfiguredServer>, McpError> {
for server in &config.supplied {
McpServer::validate_name(server.name()).map_err(|reason| McpError::Invalid {
origin: "the supplied MCP server list".to_string(),
name: server.name().to_string(),
reason: reason.to_string(),
})?;
}
Ok(layer(config.supplied.iter().cloned().map(|server| {
ConfiguredServer {
server,
sse_inferred: false,
}
})))
}
fn layer(servers: impl IntoIterator<Item = ConfiguredServer>) -> Vec<ConfiguredServer> {
let roots = servers.into_iter().enumerate().map(|(index, server)| {
let mut root = std::collections::BTreeMap::new();
root.insert(server.server.name().to_string(), (index, server));
Ok::<_, std::convert::Infallible>(root)
});
let mut kept = crate::named_roots::merge_roots(roots).expect("the roots are infallible");
kept.sort_by_key(|(index, _)| *index);
kept.into_iter().map(|(_, server)| server).collect()
}
fn read(path: PathBuf, scope: ContextScope) -> Result<ReadSource, McpError> {
let text = std::fs::read_to_string(&path).map_err(|source| McpError::Read {
path: path.clone(),
source,
})?;
let configured = file::parse(&path, &text)?;
Ok(ReadSource {
path,
scope,
configured,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn config(global: Option<PathBuf>) -> McpConfig {
McpConfig {
workspace_file: PathBuf::from(DEFAULT_WORKSPACE_MCP_FILE),
global_dir: global,
supplied: Vec::new(),
}
}
fn write(path: &Path, body: &str) {
std::fs::create_dir_all(path.parent().expect("a parent")).expect("create dir");
std::fs::write(path, body).expect("write file");
}
fn one_stdio(name: &str, command: &str) -> String {
format!(r#"{{"mcpServers":{{"{name}":{{"command":"{command}"}}}}}}"#)
}
#[test]
fn a_redacted_url_keeps_the_address_and_drops_the_secrets() {
assert_eq!(
redacted_url("https://user:pass@example.com/mcp?key=sk-live"),
"https://[redacted]@example.com/mcp?[redacted]"
);
assert_eq!(
redacted_url("https://example.com/mcp"),
"https://example.com/mcp",
"an innocent URL passes through recognizably"
);
assert_eq!(
redacted_url("not a url?token=x"),
"not a url?[redacted]",
"surgery must survive what a parser would refuse"
);
}
#[test]
fn nothing_on_disk_means_no_servers() {
let tmp = tempfile::tempdir().expect("tempdir");
let found = discover(tmp.path(), &config(None)).expect("no file is not an error");
assert!(found.is_empty());
}
#[test]
fn a_workspace_file_is_found() {
let tmp = tempfile::tempdir().expect("tempdir");
write(
&tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE),
&one_stdio("fs", "npx"),
);
let found = discover(tmp.path(), &config(None)).expect("discovery succeeds");
assert_eq!(found.len(), 1);
assert_eq!(found[0].scope, ContextScope::Workspace);
assert_eq!(found[0].servers.len(), 1);
assert_eq!(found[0].servers[0].name(), "fs");
}
#[test]
fn the_workspace_file_outranks_the_global_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
write(
&tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE),
&one_stdio("fs", "workspace-command"),
);
write(
&global.join(DEFAULT_GLOBAL_MCP_FILE),
&one_stdio("fs", "global-command"),
);
let found =
discover(tmp.path(), &config(Some(global.clone()))).expect("discovery succeeds");
assert_eq!(found.len(), 2);
assert_eq!(found[0].scope, ContextScope::Workspace);
assert_eq!(found[1].scope, ContextScope::Global);
let layered = servers(tmp.path(), &config(Some(global))).expect("layering succeeds");
assert_eq!(layered.len(), 1, "one name is one server");
assert_eq!(
layered[0].as_stdio().expect("stdio").command,
"workspace-command"
);
}
#[test]
fn a_global_server_survives_alongside_a_workspace_one() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
write(
&tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE),
&one_stdio("local", "a"),
);
write(
&global.join(DEFAULT_GLOBAL_MCP_FILE),
&one_stdio("shared", "b"),
);
let layered = servers(tmp.path(), &config(Some(global))).expect("layering succeeds");
let names: Vec<&str> = layered.iter().map(McpServer::name).collect();
assert_eq!(names, vec!["local", "shared"]);
}
#[test]
fn a_supplied_server_outranks_both_files() {
let tmp = tempfile::tempdir().expect("tempdir");
write(
&tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE),
&one_stdio("fs", "from-the-file"),
);
let config = McpConfig {
supplied: vec![McpServer::Stdio(McpServerConfig {
name: "fs".to_string(),
command: "from-the-client".to_string(),
args: Vec::new(),
env: Default::default(),
cwd: None,
})],
..config(None)
};
let layered = servers(tmp.path(), &config).expect("layering succeeds");
assert_eq!(layered.len(), 1);
assert_eq!(
layered[0].as_stdio().expect("stdio").command,
"from-the-client",
"the client is answering for this session in particular"
);
}
#[test]
fn a_supplied_server_with_a_double_underscore_name_is_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = McpConfig {
supplied: vec![McpServer::Stdio(McpServerConfig {
name: "evil__foo".to_string(),
command: "from-the-host".to_string(),
args: Vec::new(),
env: Default::default(),
cwd: None,
})],
..config(None)
};
let error =
servers(tmp.path(), &config).expect_err("mentra's split would misparse this name");
assert!(matches!(error, McpError::Invalid { .. }), "{error}");
assert!(error.to_string().contains("tool-name encoding"), "{error}");
}
#[test]
fn a_supplied_server_with_a_trailing_underscore_name_is_rejected() {
let tmp = tempfile::tempdir().expect("tempdir");
let config = McpConfig {
supplied: vec![McpServer::Stdio(McpServerConfig {
name: "evil_".to_string(),
command: "from-the-host".to_string(),
args: Vec::new(),
env: Default::default(),
cwd: None,
})],
..config(None)
};
let error = servers(tmp.path(), &config)
.expect_err("a trailing `_` can join a tool's leading `_` into a fake separator");
assert!(matches!(error, McpError::Invalid { .. }), "{error}");
assert!(error.to_string().contains("ending in `_`"), "{error}");
}
#[test]
fn the_same_file_reached_twice_is_read_once() {
let tmp = tempfile::tempdir().expect("tempdir");
let global = tmp.path().join("global");
write(
&global.join(DEFAULT_GLOBAL_MCP_FILE),
&one_stdio("fs", "npx"),
);
let found = discover(
&global,
&McpConfig {
workspace_file: PathBuf::from(DEFAULT_GLOBAL_MCP_FILE),
global_dir: Some(global.clone()),
supplied: Vec::new(),
},
)
.expect("discovery succeeds");
assert_eq!(found.len(), 1);
assert_eq!(found[0].scope, ContextScope::Workspace);
}
#[test]
fn a_directory_where_the_file_should_be_is_ignored() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE))
.expect("create a directory with the file's name");
let found = discover(tmp.path(), &config(None)).expect("discovery succeeds");
assert!(found.is_empty());
}
#[test]
fn a_malformed_file_is_an_error_not_a_silent_skip() {
let tmp = tempfile::tempdir().expect("tempdir");
write(&tmp.path().join(DEFAULT_WORKSPACE_MCP_FILE), "{not json");
let error = discover(tmp.path(), &config(None)).expect_err("malformed is an error");
assert!(matches!(error, McpError::Parse { .. }), "{error}");
}
#[test]
fn defaults_configure_no_servers_of_their_own() {
let config = McpConfig::default();
assert_eq!(config.workspace_file, PathBuf::from(".mcp.json"));
assert!(config.supplied.is_empty());
}
#[test]
fn supplying_servers_returns_a_new_config() {
let base = McpConfig::default();
let supplied = base
.clone()
.with_supplied(vec![McpServer::Sse(McpSseServerConfig::new(
"obs",
"https://example.com/sse",
))]);
assert!(base.supplied.is_empty(), "the original is untouched");
assert_eq!(supplied.supplied.len(), 1);
}
#[test]
fn a_servers_environment_is_not_printed() {
let server = McpServer::Stdio(McpServerConfig {
name: "gh".to_string(),
command: "server".to_string(),
args: vec!["--org".to_string(), "acme".to_string()],
env: [("GITHUB_TOKEN".to_string(), "ghp-secret-value".to_string())]
.into_iter()
.collect(),
cwd: None,
});
let printed = format!("{server:?}");
assert!(!printed.contains("ghp-secret-value"));
assert!(printed.contains("redacted"));
assert!(
printed.contains("GITHUB_TOKEN"),
"the variable's name is what makes a misconfiguration fixable"
);
assert!(
printed.contains("server") && printed.contains("acme"),
"the command and its arguments are how a spawn is debugged"
);
}
#[test]
fn a_configured_server_is_not_printed_by_whatever_holds_it() {
let config = McpConfig::default().with_supplied(vec![McpServer::Stdio(McpServerConfig {
name: "gh".to_string(),
command: "server".to_string(),
args: Vec::new(),
env: [("GITHUB_TOKEN".to_string(), "ghp-secret-value".to_string())]
.into_iter()
.collect(),
cwd: None,
})]);
assert!(!format!("{config:?}").contains("ghp-secret-value"));
}
}