magi-code 0.80.2

Repository-aware CLI coding agent for terminal work
Documentation
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()))?;
            // Project entries replace whole servers, never individual global fields.
            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"),
    }
    // A repository-controlled definition can never grant itself approval.
    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<()> {
    // Approvals are deliberately read from the global file, not merged project settings.
    // A checked-in project settings.json must not approve execution on its own.
    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;