use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use anyhow::Context;
use serde::Deserialize;
use serde_json::Value;
use super::{
core::{Settings, SettingsScope},
services::{McpServerConfig, McpServersSettings},
};
use crate::config::{
McPaths,
settings_storage::{read_settings_json_or_empty, update_settings_json},
};
type ServerSources = BTreeMap<String, (String, McpServerConfig)>;
#[derive(Default, Deserialize)]
struct McpDocument {
#[serde(default, rename = "mcpServers")]
servers: BTreeMap<String, Value>,
}
fn source_paths(paths: &McPaths) -> anyhow::Result<[PathBuf; 2]> {
let project = paths
.project_settings_file
.parent()
.and_then(Path::parent)
.context("project settings path has no project directory")?;
Ok([paths.root.join(".mcp.json"), project.join(".mcp.json")])
}
fn read_server_sources(paths: &McPaths) -> anyhow::Result<ServerSources> {
let mut servers = BTreeMap::new();
for path in source_paths(paths)? {
let Some((source, raw)) = read_mcp_document(&path)? else {
continue;
};
let document: McpDocument = serde_json::from_value(raw)
.with_context(|| format!("invalid MCP configuration in {}", path.display()))?;
if document.servers.is_empty() {
continue;
}
for (name, value) in document.servers {
super::validation::validate_mcp_server_name(&name)?;
let config = parse_server(value)
.with_context(|| format!("invalid MCP server {name} in {}", path.display()))?;
servers.insert(name, (source.clone(), config));
}
}
Ok(servers)
}
fn read_mcp_document(path: &Path) -> anyhow::Result<Option<(String, Value)>> {
use std::io::Read;
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
let file = match options.open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
anyhow::ensure!(
file.metadata()?.is_file(),
"MCP source must be a regular file: {}",
path.display()
);
let source = path
.canonicalize()?
.into_os_string()
.into_string()
.map_err(|_| anyhow::anyhow!("MCP source path must be UTF-8"))?;
let limit = crate::config::settings_storage::MAX_SETTINGS_FILE_BYTES;
let mut bytes = Vec::new();
file.take((limit + 1) as u64).read_to_end(&mut bytes)?;
anyhow::ensure!(
bytes.len() <= limit,
"MCP configuration exceeds {limit} bytes: {}",
path.display()
);
let raw = serde_json::from_slice(&bytes)
.with_context(|| format!("invalid MCP JSON in {}", path.display()))?;
Ok(Some((source, raw)))
}
fn parse_server(mut value: Value) -> anyhow::Result<McpServerConfig> {
let object = value
.as_object_mut()
.context("MCP server must be an object")?;
let transport = object
.entry("type")
.or_insert_with(|| Value::String("stdio".into()));
match transport.as_str() {
Some("stdio" | "http") => {}
Some("sse") => anyhow::bail!(
"legacy SSE transport is unsupported; use a Streamable HTTP server with type http"
),
_ => anyhow::bail!("unsupported MCP transport; expected stdio or http"),
}
object.insert("enabled".into(), Value::Bool(false));
serde_json::from_value(value).map_err(|_| anyhow::anyhow!("invalid MCP server fields"))
}
fn expand_server(config: &mut McpServerConfig) -> anyhow::Result<()> {
match config {
McpServerConfig::Stdio(stdio) => {
expand(&mut stdio.command)?;
for arg in &mut stdio.args {
expand(arg)?;
}
for value in stdio.env.values_mut() {
expand(value)?;
}
}
McpServerConfig::Http(http) => {
expand(&mut http.url)?;
for value in http.headers.values_mut() {
expand(value)?;
}
}
}
Ok(())
}
fn expand(value: &mut String) -> anyhow::Result<()> {
*value = expand_environment(value, |name| std::env::var(name).ok())?;
Ok(())
}
fn expand_environment(
input: &str,
lookup: impl Fn(&str) -> Option<String>,
) -> anyhow::Result<String> {
let mut output = String::new();
let mut rest = input;
while let Some(start) = rest.find("${") {
output.push_str(&rest[..start]);
let expression = &rest[start + 2..];
let end = expression
.find('}')
.context("unterminated MCP environment variable reference")?;
let expression_body = &expression[..end];
let (name, default) = expression_body
.split_once(":-")
.map_or((expression_body, None), |(name, default)| {
(name, Some(default))
});
anyhow::ensure!(
!name.is_empty()
&& name.bytes().enumerate().all(|(index, byte)| byte == b'_'
|| byte.is_ascii_alphabetic()
|| (index > 0 && byte.is_ascii_digit())),
"invalid MCP environment variable name"
);
let replacement = lookup(name)
.or_else(|| default.map(str::to_owned))
.with_context(|| format!("MCP environment variable {name} is not set"))?;
output.push_str(&replacement);
rest = &expression[end + 1..];
}
output.push_str(rest);
Ok(output)
}
pub(super) fn load_mcp_servers(paths: &McPaths, settings: &mut Settings) -> anyhow::Result<()> {
let global: Settings =
serde_json::from_value(read_settings_json_or_empty(&paths.settings_file)?)?;
settings.mcp_approvals = global.mcp_approvals;
let mut servers = McpServersSettings::new();
for (name, (source, mut config)) in read_server_sources(paths)? {
let approved = settings
.mcp_approvals
.get(&source)
.and_then(|names| names.get(&name))
.copied()
.unwrap_or(false);
config.set_enabled(approved);
if approved {
expand_server(&mut config)
.with_context(|| format!("invalid MCP server {name} in {source}"))?;
}
servers.insert(name, config);
}
super::validation::validate_mcp_servers_settings(&servers)?;
settings.mcp_servers = servers;
Ok(())
}
pub(super) fn set_server_approval(
paths: &McPaths,
name: &str,
enabled: bool,
) -> anyhow::Result<()> {
let mut servers = read_server_sources(paths)?;
let (source, config) = servers
.get_mut(name)
.with_context(|| format!("mcp server not found: {name}"))?;
if enabled {
expand_server(config).with_context(|| format!("invalid MCP server {name} in {source}"))?;
super::validation::validate_mcp_server(name, config)?;
}
update_settings_json(paths, SettingsScope::Global, |raw| {
super::json::set_path(
raw,
&["capabilities", "mcp_approvals", source, name],
Value::Bool(enabled),
)?;
Ok(())
})?;
Ok(())
}
#[cfg(test)]
mod tests;