pub(crate) mod connections;
mod expand;
mod file;
use std::path::{Path, PathBuf};
use mentra::{McpServerConfig, McpSseServerConfig};
use thiserror::Error;
use crate::context::ContextScope;
pub const DEFAULT_WORKSPACE_MCP_FILE: &str = ".mcp.json";
pub const DEFAULT_GLOBAL_MCP_FILE: &str = "mcp.json";
#[derive(Clone)]
pub enum McpServer {
Stdio(McpServerConfig),
Sse(McpSseServerConfig),
}
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",
&config
.env
.keys()
.map(|key| (key, "<redacted>"))
.collect::<std::collections::BTreeMap<_, _>>(),
)
.finish(),
Self::Sse(config) => f.debug_tuple("Sse").field(config).finish(),
}
}
}
impl McpServer {
pub fn name(&self) -> &str {
match self {
Self::Stdio(config) => &config.name,
Self::Sse(config) => &config.name,
}
}
pub fn as_stdio(&self) -> Option<&McpServerConfig> {
match self {
Self::Stdio(config) => Some(config),
Self::Sse(_) => None,
}
}
pub fn as_sse(&self) -> Option<&McpSseServerConfig> {
match self {
Self::Sse(config) => Some(config),
Self::Stdio(_) => None,
}
}
}
#[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::ContextConfig::default().global_dir,
supplied: Vec::new(),
}
}
}
impl McpConfig {
pub fn with_supplied(self, supplied: Vec<McpServer>) -> Self {
Self { supplied, ..self }
}
}
#[derive(Debug, Clone)]
pub struct McpSource {
pub path: PathBuf,
pub scope: ContextScope,
pub servers: Vec<McpServer>,
}
#[derive(Debug, Error)]
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}: MCP server `{name}` needs the {transport} transport, which basis cannot serve"
)]
UnsupportedTransport {
origin: String,
name: String,
transport: 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> {
let mut sources = Vec::new();
let workspace_file = workspace.join(&config.workspace_file);
if 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> {
let discovered = discover(workspace, config)?;
Ok(layer(
config.supplied.iter().cloned().chain(
discovered
.into_iter()
.flat_map(|source| source.servers.into_iter()),
),
))
}
fn layer(servers: impl IntoIterator<Item = McpServer>) -> Vec<McpServer> {
let mut kept: Vec<McpServer> = Vec::new();
for server in servers {
if !kept.iter().any(|seen| seen.name() == server.name()) {
kept.push(server);
}
}
kept
}
fn read(path: PathBuf, scope: ContextScope) -> Result<McpSource, McpError> {
let text = std::fs::read_to_string(&path).map_err(|source| McpError::Read {
path: path.clone(),
source,
})?;
let servers = file::parse(&path, &text)?;
Ok(McpSource {
path,
scope,
servers,
})
}
#[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 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 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"));
}
}