use std::collections::HashMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use everruns_core::{ScopedMcpServer, ScopedMcpServers, merge_scoped_mcp_servers};
use serde::Deserialize;
pub const MCP_CONFIG_FILE: &str = ".mcp.json";
#[derive(Debug, Deserialize)]
struct McpConfigFile {
#[serde(default, rename = "mcpServers")]
mcp_servers: ScopedMcpServers,
}
pub fn global_mcp_config_path() -> Option<PathBuf> {
dirs::config_dir().map(|p| p.join("yolop").join("mcp.json"))
}
pub fn load_mcp_servers(workspace_root: &Path) -> ScopedMcpServers {
load_merged(global_mcp_config_path().as_deref(), workspace_root)
}
fn load_merged(global_path: Option<&Path>, workspace_root: &Path) -> ScopedMcpServers {
let global = global_path.map(read_scope).unwrap_or_default();
let workspace = read_scope(&workspace_root.join(MCP_CONFIG_FILE));
let mut merged = merge_scoped_mcp_servers(&global, &workspace);
for server in merged.values_mut() {
expand_server_env(server);
}
merged
}
fn read_scope(path: &Path) -> ScopedMcpServers {
match read_config(path) {
Ok(servers) => servers,
Err(error) => {
tracing::warn!(path = %path.display(), %error, "ignoring malformed MCP config");
ScopedMcpServers::default()
}
}
}
fn read_config(path: &Path) -> Result<ScopedMcpServers> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(ScopedMcpServers::default());
}
Err(error) => return Err(error).with_context(|| format!("reading {}", path.display())),
};
let config: McpConfigFile =
serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))?;
Ok(config.mcp_servers)
}
fn expand_server_env(server: &mut ScopedMcpServer) {
server.url = expand_env(&server.url);
server.command = server.command.as_deref().map(expand_env);
for arg in &mut server.args {
*arg = expand_env(arg);
}
replace_values(&mut server.headers);
replace_values(&mut server.env);
}
fn replace_values(map: &mut HashMap<String, String>) {
for value in map.values_mut() {
*value = expand_env(value);
}
}
fn expand_env(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut rest = input;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
match after.find('}') {
Some(end) => {
let name = &after[..end];
match std::env::var(name) {
Ok(value) => out.push_str(&value),
Err(_) => {
out.push_str(&rest[start..start + 2 + end + 1]);
}
}
rest = &after[end + 1..];
}
None => {
out.push_str(&rest[start..]);
rest = "";
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
use everruns_core::McpServerTransportType;
fn write(dir: &Path, contents: &str) {
std::fs::write(dir.join(MCP_CONFIG_FILE), contents).unwrap();
}
fn load_workspace(dir: &Path) -> ScopedMcpServers {
load_merged(None, dir)
}
#[test]
fn missing_file_yields_no_servers() {
let dir = tempfile::tempdir().unwrap();
assert!(load_workspace(dir.path()).is_empty());
}
#[test]
fn parses_http_server_with_headers() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
r#"{ "mcpServers": {
"docs": {
"type": "http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer t0ken" }
}
}}"#,
);
let servers = load_workspace(dir.path());
let docs = servers.get("docs").expect("docs server");
assert_eq!(docs.transport_type, McpServerTransportType::Http);
assert_eq!(docs.url, "https://example.com/mcp");
assert_eq!(
docs.headers.get("Authorization").map(String::as_str),
Some("Bearer t0ken")
);
assert!(docs.tool_discovery, "tool discovery defaults on");
}
#[test]
fn type_defaults_to_http() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
r#"{ "mcpServers": { "b": { "url": "https://b.example.com/mcp" } } }"#,
);
let servers = load_workspace(dir.path());
assert_eq!(servers["b"].transport_type, McpServerTransportType::Http);
}
#[test]
fn empty_object_is_ok() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "{}");
assert!(load_workspace(dir.path()).is_empty());
}
#[test]
fn malformed_json_is_an_error_at_the_scope_level() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "{ not json");
assert!(read_config(&dir.path().join(MCP_CONFIG_FILE)).is_err());
}
#[test]
fn malformed_scope_is_skipped_not_fatal() {
let global = tempfile::tempdir().unwrap();
let global_path = global.path().join("mcp.json");
std::fs::write(&global_path, "{ not json").unwrap();
let workspace = tempfile::tempdir().unwrap();
write(
workspace.path(),
r#"{ "mcpServers": { "ws": { "url": "https://ws.example.com/mcp" } } }"#,
);
let servers = load_merged(Some(&global_path), workspace.path());
assert!(
servers.contains_key("ws"),
"valid workspace server survives a malformed global config: {servers:?}"
);
}
#[test]
fn parses_stdio_server() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
r#"{ "mcpServers": {
"fs": {
"type": "stdio",
"command": "mcp-server-filesystem",
"args": ["/work"],
"env": { "RUST_LOG": "info" }
}
}}"#,
);
let servers = load_workspace(dir.path());
let fs = servers.get("fs").expect("fs server");
assert_eq!(fs.transport_type, McpServerTransportType::Stdio);
assert_eq!(fs.command.as_deref(), Some("mcp-server-filesystem"));
assert_eq!(fs.args, vec!["/work".to_string()]);
assert_eq!(fs.env.get("RUST_LOG").map(String::as_str), Some("info"));
}
#[test]
fn expands_env_placeholders() {
let _guard = crate::test_env::lock();
unsafe {
std::env::set_var("YOLOP_TEST_MCP_TOKEN", "s3cret");
std::env::set_var("YOLOP_TEST_MCP_ROOT", "/srv/work");
}
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
r#"{ "mcpServers": {
"docs": {
"type": "http",
"url": "https://example.com/mcp",
"headers": { "Authorization": "Bearer ${YOLOP_TEST_MCP_TOKEN}" }
},
"fs": {
"type": "stdio",
"command": "mcp-server-filesystem",
"args": ["${YOLOP_TEST_MCP_ROOT}"]
}
}}"#,
);
let servers = load_workspace(dir.path());
assert_eq!(
servers["docs"]
.headers
.get("Authorization")
.map(String::as_str),
Some("Bearer s3cret")
);
assert_eq!(servers["fs"].args, vec!["/srv/work".to_string()]);
unsafe {
std::env::remove_var("YOLOP_TEST_MCP_TOKEN");
std::env::remove_var("YOLOP_TEST_MCP_ROOT");
}
}
#[test]
fn unset_env_placeholder_is_left_intact() {
let _guard = crate::test_env::lock();
unsafe {
std::env::remove_var("YOLOP_UNSET_VAR_XYZ");
}
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
r#"{ "mcpServers": {
"docs": { "type": "http", "url": "https://example.com/${YOLOP_UNSET_VAR_XYZ}" }
}}"#,
);
let servers = load_workspace(dir.path());
assert_eq!(
servers["docs"].url,
"https://example.com/${YOLOP_UNSET_VAR_XYZ}"
);
}
#[test]
fn expand_env_handles_multiple_and_adjacent() {
let _guard = crate::test_env::lock();
unsafe {
std::env::set_var("YOLOP_TEST_A", "a");
std::env::set_var("YOLOP_TEST_B", "b");
}
assert_eq!(expand_env("${YOLOP_TEST_A}-${YOLOP_TEST_B}"), "a-b");
assert_eq!(expand_env("x${YOLOP_TEST_A}${YOLOP_TEST_B}y"), "xaby");
assert_eq!(expand_env("no placeholders"), "no placeholders");
assert_eq!(expand_env("${unterminated"), "${unterminated");
unsafe {
std::env::remove_var("YOLOP_TEST_A");
std::env::remove_var("YOLOP_TEST_B");
}
}
}