magi-code 0.78.0

Repository-aware CLI coding agent for terminal work
Documentation
use super::*;

// Exercise MCP parsing through the file boundary rather than settings deserialization.
fn parse_mcp_settings(raw: &str) -> anyhow::Result<Settings> {
    let value: serde_json::Value = serde_json::from_str(raw)?;
    let temp = tempfile::TempDir::new()?;
    let paths =
        McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("project"));
    fs::create_dir_all(&paths.root)?;
    fs::write(
        paths.root.join(".mcp.json"),
        serde_json::to_vec(&json!({"mcpServers": value["mcp_servers"]}))?,
    )?;
    read_settings(&paths)
}

fn parse_validated_settings(raw: &str) -> anyhow::Result<Settings> {
    if serde_json::from_str::<serde_json::Value>(raw)?
        .get("mcp_servers")
        .is_some()
    {
        let mut settings = parse_mcp_settings(raw)?;
        for config in settings.mcp_servers.values_mut() {
            config.set_enabled(true);
        }
        super::super::validation::validate_settings(&settings)?;
        Ok(settings)
    } else {
        super::parse_validated_settings(raw)
    }
}

#[test]
fn mcp_servers_parse_defaults_validate_and_serialize() {
    let settings = parse_mcp_settings(
        r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30}}}"#,
    )
    .unwrap();
    let config = settings.mcp_servers.get("mock").unwrap();
    match config {
        McpServerConfig::Stdio(stdio) => {
            assert_eq!(stdio.command, "node");
            assert_eq!(stdio.args, vec!["server.js"]);
            assert!(stdio.env.is_empty());
            assert!(!stdio.enabled);
            assert_eq!(stdio.timeout, Some(30));
        }
        McpServerConfig::Http(_) => panic!("expected stdio config"),
    }

    let absent: Settings = serde_json::from_str("{}").unwrap();
    assert!(absent.mcp_servers.is_empty());
    let serialized = serde_json::to_value(settings).unwrap();
    assert!(serialized["capabilities"].get("mcp").is_none());
}

#[test]
fn mcp_http_servers_parse_defaults_validate_serialize_and_redact_debug() {
    let settings = parse_mcp_settings(
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp?token=url-secret#frag","headers":{"Authorization":"{env:MCP_TOKEN}","X-Team":"platform"}}}}"#,
    )
    .unwrap();
    let config = settings.mcp_servers.get("remote").unwrap();
    match config {
        McpServerConfig::Http(http) => {
            assert_eq!(
                http.url,
                "https://mcp.example.test/mcp?token=url-secret#frag"
            );
            assert!(!http.enabled);
            assert_eq!(http.timeout, None);
            assert_eq!(http.headers["Authorization"], "{env:MCP_TOKEN}");
            assert!(http.oauth.is_none());
            let debug = format!("{http:?}");
            assert!(debug.contains("[REDACTED]"), "{debug}");
            assert!(!debug.contains("platform"), "{debug}");
            assert!(!debug.contains("MCP_TOKEN"), "{debug}");
            assert!(!debug.contains("url-secret"), "{debug}");
            assert!(!debug.contains("token="), "{debug}");
        }
        McpServerConfig::Stdio(_) => panic!("expected http config"),
    }
    let serialized = serde_json::to_value(settings).unwrap();
    assert!(serialized["capabilities"].get("mcp").is_none());
}

#[test]
fn mcp_http_servers_reject_invalid_url_headers_and_timeout() {
    for raw in [
        r#"{"mcp_servers":{"remote":{"type":"http","url":"ftp://mcp.example.test/mcp"}}}"#,
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://user:pass@mcp.example.test/mcp"}}}"#,
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Bad:Name":"x"}}}}"#,
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"bad\nvalue"}}}}"#,
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","timeout":301}}}"#,
    ] {
        assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
    }

    for raw in [
        r#"{"mcp_servers":{"local":{"type":"http","url":"http://localhost:8787/mcp"}}}"#,
        r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.1:8787/mcp"}}}"#,
        r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.2:8787/mcp"}}}"#,
        r#"{"mcp_servers":{"local":{"type":"http","url":"http://[::1]:8787/mcp"}}}"#,
    ] {
        assert!(parse_validated_settings(raw).is_ok(), "rejected {raw}");
    }
    let remote =
        r#"{"mcp_servers":{"remote":{"type":"http","url":"http://mcp.example.test/mcp"}}}"#;
    assert!(
        parse_validated_settings(remote).is_err(),
        "accepted {remote}"
    );

    let userinfo = r#"{"mcp_servers":{"remote":{"type":"http","url":"https://user:pass@mcp.example.test/mcp"}}}"#;
    assert!(
        parse_validated_settings(userinfo).is_err(),
        "accepted {userinfo}"
    );
}

#[test]
fn mcp_http_oauth_config_parses_validates_serializes_and_redacts_debug() {
    let settings = parse_mcp_settings(
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"X-Team":"platform"},"oauth":{"client_id":"public-client","scopes":["search","offline_access"],"authorization_server":"https://auth.example.test"}}}}"#,
    )
    .unwrap();
    let config = settings.mcp_servers.get("remote").unwrap();
    let McpServerConfig::Http(http) = config else {
        panic!("expected http config");
    };
    let oauth = http.oauth.as_ref().unwrap();
    assert_eq!(oauth.client_id.as_deref(), Some("public-client"));
    assert_eq!(oauth.scopes, vec!["search", "offline_access"]);
    assert_eq!(
        oauth.authorization_server.as_deref(),
        Some("https://auth.example.test")
    );
    let debug = format!("{oauth:?}");
    assert!(debug.contains("[REDACTED]"), "{debug}");
    assert!(!debug.contains("public-client"), "{debug}");

    let serialized = serde_json::to_value(settings).unwrap();
    assert!(serialized["capabilities"].get("mcp").is_none());
}

#[test]
fn mcp_http_oauth_rejects_authorization_headers_and_invalid_fields() {
    for raw in [
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Authorization":"{env:MCP_TOKEN}"},"oauth":{}}}}"#,
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","headers":{"Proxy-Authorization":"{env:MCP_TOKEN}"},"oauth":{}}}}"#,
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"client_id":" "}}}}"#,
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"scopes":[""]}}}}"#,
        r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"scopes":["bad\u0007scope"]}}}}"#,
    ] {
        assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
    }

    for raw in [
        r#"{"mcp_servers":{"local":{"type":"http","url":"http://localhost:8787/mcp","oauth":{"authorization_server":"http://localhost:8788"}}}}"#,
        r#"{"mcp_servers":{"local":{"type":"http","url":"http://127.0.0.1:8787/mcp","oauth":{"authorization_server":"http://127.0.0.2:8788"}}}}"#,
    ] {
        assert!(parse_validated_settings(raw).is_ok(), "rejected {raw}");
    }
    let remote = r#"{"mcp_servers":{"remote":{"type":"http","url":"https://mcp.example.test/mcp","oauth":{"authorization_server":"http://auth.example.test"}}}}"#;
    assert!(
        parse_validated_settings(remote).is_err(),
        "accepted {remote}"
    );
}

#[test]
fn settings_schema_includes_approvals_not_server_definitions() {
    let schema = schemars::schema_for!(Settings);
    let schema_text = serde_json::to_string(&schema).unwrap();
    assert!(schema_text.contains("mcp_approvals"));
    assert!(!schema_text.contains("McpHttpServerConfig"));
    assert!(!schema_text.contains("McpStdioServerConfig"));
}

#[test]
fn mcp_servers_reject_invalid_names_command_and_timeout() {
    for raw in [
        r#"{"mcp_servers":{"bad/name":{"type":"stdio","command":"node"}}}"#,
        r#"{"mcp_servers":{"bad__name":{"type":"stdio","command":"node"}}}"#,
        r#"{"mcp_servers":{"bad_":{"type":"stdio","command":"node"}}}"#,
        r#"{"mcp_servers":{"mock":{"type":"stdio","command":" "}}}"#,
        r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","timeout":0}}}"#,
        r#"{"mcp_servers":{"mock":{"type":"stdio","command":"node","timeout":301}}}"#,
    ] {
        assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
    }
}

#[test]
fn lsp_settings_parse_defaults_validate_and_serialize() {
    let absent: Settings = serde_json::from_str("{}").unwrap();
    assert_eq!(absent.lsp, LspSettings::default());
    assert!(!absent.lsp.enabled);
    assert!(absent.lsp.inject_diagnostics_on_edit);
    assert_eq!(
        absent.lsp.diagnostics_wait_ms,
        DEFAULT_LSP_DIAGNOSTICS_WAIT_MS
    );
    assert_eq!(
        absent.lsp.idle_shutdown_minutes,
        DEFAULT_LSP_IDLE_SHUTDOWN_MINUTES
    );
    assert!(serde_json::to_value(&absent).unwrap().get("lsp").is_none());

    let settings: Settings = serde_json::from_str(
        r#"{"capabilities":{"lsp":{"enabled":true,"inject_diagnostics_on_edit":false,"diagnostics_wait_ms":1500,"idle_shutdown_minutes":30,"servers":{"rust-analyzer":{"command":"rust-analyzer","args":["--log-file","ra.log"],"enabled":false}}}}}"#,
    )
    .unwrap();
    validate_settings(&settings).unwrap();
    assert!(settings.lsp.enabled);
    assert!(!settings.lsp.inject_diagnostics_on_edit);
    assert_eq!(settings.lsp.diagnostics_wait_ms, 1500);
    assert_eq!(settings.lsp.idle_shutdown_minutes, 30);
    let rust_analyzer = settings.lsp.servers.get("rust-analyzer").unwrap();
    assert_eq!(rust_analyzer.command, "rust-analyzer");
    assert_eq!(rust_analyzer.args, vec!["--log-file", "ra.log"]);
    assert!(!rust_analyzer.enabled);

    let serialized = serde_json::to_value(settings).unwrap();
    assert_eq!(serialized["capabilities"]["lsp"]["enabled"], true);
    assert_eq!(
        serialized["capabilities"]["lsp"]["servers"]["rust-analyzer"]["command"],
        "rust-analyzer"
    );
}

#[test]
fn lsp_settings_reject_invalid_values() {
    for raw in [
        r#"{"capabilities":{"lsp":{"diagnostics_wait_ms":0}}}"#,
        r#"{"capabilities":{"lsp":{"diagnostics_wait_ms":30001}}}"#,
        r#"{"capabilities":{"lsp":{"idle_shutdown_minutes":0}}}"#,
        r#"{"capabilities":{"lsp":{"idle_shutdown_minutes":241}}}"#,
        r#"{"capabilities":{"lsp":{"servers":{"":{"command":"rust-analyzer"}}}}}"#,
        r#"{"capabilities":{"lsp":{"servers":{"bad__name":{"command":"rust-analyzer"}}}}}"#,
        r#"{"capabilities":{"lsp":{"servers":{"rust-analyzer":{"command":" "}}}}}"#,
    ] {
        assert!(parse_validated_settings(raw).is_err(), "accepted {raw}");
    }
}

#[test]
fn lsp_settings_preserved_and_removed_by_settings_update() {
    let mut raw = json!({
        "capabilities": {"lsp": {
            "enabled": true,
            "servers": {"rust-analyzer": {"command": "rust-analyzer"}}
        }},
        "agent": {"primary_agent": "old"}
    });
    let mut settings: Settings = serde_json::from_value(raw.clone()).unwrap();
    let before = settings.clone();
    settings.selected_primary_agent = Some("new".to_string());

    update_raw_from_settings(&mut raw, &before, &settings, SettingsScope::Global).unwrap();

    assert_eq!(raw["capabilities"]["lsp"]["enabled"], true);
    assert_eq!(
        raw["capabilities"]["lsp"]["servers"]["rust-analyzer"]["command"],
        "rust-analyzer"
    );
    assert_eq!(raw["agent"]["primary_agent"], "new");

    let before = settings.clone();
    settings.lsp = LspSettings::default();
    update_raw_from_settings(&mut raw, &before, &settings, SettingsScope::Global).unwrap();
    assert!(raw.pointer("/capabilities/lsp").is_none());
}

#[test]
fn old_mcp_server_definitions_are_not_migrated_by_settings_update() {
    let temp = tempfile::TempDir::new().unwrap();
    let paths = McPaths::from_root(temp.path().join("mc"));
    fs::create_dir_all(&paths.root).unwrap();
    fs::write(
        &paths.settings_file,
        r#"{"future_setting":true,"capabilities":{"mcp":{"mock":{"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30}}}}"#,
    )
    .unwrap();

    set_selected_model(&paths, "provider", "model").unwrap();

    let value: serde_json::Value =
        serde_json::from_str(&fs::read_to_string(&paths.settings_file).unwrap()).unwrap();
    assert_eq!(
        value["capabilities"]["mcp"]["mock"],
        json!({"type":"stdio","command":"node","args":["server.js"],"env":{},"enabled":true,"timeout":30})
    );
    assert!(read_settings(&paths).unwrap().mcp_servers.is_empty());
    assert_eq!(value["future_setting"], true);
}

#[test]
fn set_mcp_server_enabled_updates_stdio_http_and_preserves_unknowns() {
    let temp = tempfile::TempDir::new().unwrap();
    let paths = McPaths::from_root(temp.path().join("mc"));
    fs::create_dir_all(&paths.root).unwrap();
    fs::write(
        &paths.settings_file,
        r#"{
            "future_setting":{"keep":true},
            "capabilities":{"mcp_approvals":{}}
        }"#,
    )
    .unwrap();
    fs::write(paths.root.join(".mcp.json"), r#"{"mcpServers":{"mock":{"command":"node"},"remote":{"type":"http","url":"https://mcp.example.test/mcp"}}}"#).unwrap();

    set_mcp_server_enabled(&paths, "mock", false).unwrap();
    set_mcp_server_enabled(&paths, "remote", true).unwrap();

    let value = read_settings_value(&paths);
    assert_eq!(value["future_setting"]["keep"], true);
    let settings = read_settings(&paths).unwrap();
    assert!(!settings.mcp_servers["mock"].enabled());
    assert!(settings.mcp_servers["remote"].enabled());
    assert!(value["capabilities"].get("mcp").is_none());
}

#[test]
fn set_mcp_server_enabled_rejects_missing_or_invalid_name() {
    let temp = tempfile::TempDir::new().unwrap();
    let paths = McPaths::from_root(temp.path().join("mc"));
    fs::create_dir_all(&paths.root).unwrap();
    fs::write(&paths.settings_file, r#"{"capabilities":{"mcp":{}}}"#).unwrap();

    let missing = set_mcp_server_enabled(&paths, "missing", true)
        .unwrap_err()
        .to_string();
    assert!(
        missing.contains("mcp server not found: missing"),
        "{missing}"
    );

    let invalid = set_mcp_server_enabled(&paths, "bad/name", true)
        .unwrap_err()
        .to_string();
    assert!(invalid.contains("mcp server name"), "{invalid}");
    let trailing = set_mcp_server_enabled(&paths, "bad_", true)
        .unwrap_err()
        .to_string();
    assert!(trailing.contains("must not end with '_'"), "{trailing}");
    assert!(trailing.contains("ambiguous"), "{trailing}");
}