magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;

use super::helpers::default_true;

pub type McpServersSettings = BTreeMap<String, McpServerConfig>;
pub type LspServersSettings = BTreeMap<String, LspServerConfig>;

pub const DEFAULT_LSP_DIAGNOSTICS_WAIT_MS: u64 = 2_000;
pub const DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES: u64 = 10;
pub const MAX_LSP_DIAGNOSTICS_WAIT_MS: u64 = 30_000;
pub const MAX_LSP_IDLE_SHUTDOWN_MINUTES: u64 = 240;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct LspSettings {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default = "default_true")]
    pub inject_diagnostics_on_edit: bool,
    #[serde(default = "default_lsp_diagnostics_wait_ms")]
    pub diagnostics_wait_ms: u64,
    #[serde(default = "default_lsp_idle_shutdown_minutes")]
    pub idle_shutdown_minutes: u64,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub servers: LspServersSettings,
}

impl Default for LspSettings {
    fn default() -> Self {
        Self {
            enabled: false,
            inject_diagnostics_on_edit: true,
            diagnostics_wait_ms: default_lsp_diagnostics_wait_ms(),
            idle_shutdown_minutes: default_lsp_idle_shutdown_minutes(),
            servers: BTreeMap::new(),
        }
    }
}

impl LspSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct LspServerConfig {
    pub command: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
    #[serde(default = "default_true")]
    pub enabled: bool,
}

pub(crate) fn default_lsp_diagnostics_wait_ms() -> u64 {
    DEFAULT_LSP_DIAGNOSTICS_WAIT_MS
}

pub(crate) fn default_lsp_idle_shutdown_minutes() -> u64 {
    DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES
}

pub const DEFAULT_MCP_TIMEOUT_SECONDS: u64 = 30;
pub const MAX_MCP_TIMEOUT_SECONDS: u64 = 300;

#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum McpServerConfig {
    Stdio(McpStdioServerConfig),
    Http(McpHttpServerConfig),
}

impl std::fmt::Debug for McpServerConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Stdio(config) => f.debug_tuple("Stdio").field(config).finish(),
            Self::Http(config) => f.debug_tuple("Http").field(config).finish(),
        }
    }
}

impl McpServerConfig {
    pub(crate) fn enabled(&self) -> bool {
        match self {
            Self::Stdio(config) => config.enabled,
            Self::Http(config) => config.enabled,
        }
    }

    pub(crate) fn set_enabled(&mut self, enabled: bool) {
        match self {
            Self::Stdio(config) => config.enabled = enabled,
            Self::Http(config) => config.enabled = enabled,
        }
    }
}

#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct McpStdioServerConfig {
    pub command: String,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub args: Vec<String>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub env: BTreeMap<String, String>,
    #[serde(default = "default_true")]
    pub enabled: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout: Option<u64>,
}

impl std::fmt::Debug for McpStdioServerConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("McpStdioServerConfig")
            .field("command", &self.command)
            .field("args", &self.args)
            .field("env", &format_args!("<{} vars redacted>", self.env.len()))
            .field("enabled", &self.enabled)
            .field("timeout", &self.timeout)
            .finish()
    }
}

#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct McpHttpServerConfig {
    pub url: String,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub headers: BTreeMap<String, String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub oauth: Option<McpOAuthConfig>,
    #[serde(default = "default_true")]
    pub enabled: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout: Option<u64>,
}

#[derive(Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
pub struct McpOAuthConfig {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub scopes: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub authorization_server: Option<String>,
}

impl std::fmt::Debug for McpOAuthConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("McpOAuthConfig")
            .field("client_id", &self.client_id.as_ref().map(|_| "[REDACTED]"))
            .field("scopes", &self.scopes)
            .field(
                "authorization_server",
                &self
                    .authorization_server
                    .as_ref()
                    .map(|url| sanitize_mcp_http_url_for_display(url)),
            )
            .finish()
    }
}

impl std::fmt::Debug for McpHttpServerConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("McpHttpServerConfig")
            .field("url", &sanitize_mcp_http_url_for_display(&self.url))
            .field(
                "headers",
                &crate::mcp::headers::redact_headers(&self.headers),
            )
            .field("oauth", &self.oauth)
            .field("enabled", &self.enabled)
            .field("timeout", &self.timeout)
            .finish()
    }
}

fn sanitize_mcp_http_url_for_display(url: &str) -> String {
    match reqwest::Url::parse(url) {
        Ok(parsed) => {
            let host = parsed.host_str().unwrap_or("<unknown>");
            let port = parsed
                .port()
                .map(|port| format!(":{port}"))
                .unwrap_or_default();
            format!("{}://{}{}{}", parsed.scheme(), host, port, parsed.path())
        }
        Err(_) => "<invalid-url>".to_string(),
    }
}