crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_config` MCP tool handler.
//!
//! Returns effective LLM and embedding backend configuration without exposing
//! any secrets. The response tells the AI whether `--live-reflect` will work
//! before calling `cortex_session_close`, and whether `cortex_memory_embed`
//! will use a real embedding backend or the deterministic stub.
//!
//! Gate: [`GateId::HealthRead`].
//! Tier: session — read-only, no confirmation needed.

use serde_json::{json, Value};

use crate::{GateId, ToolError, ToolHandler};

/// MCP tool: `cortex_config`.
///
/// Schema:
/// ```text
/// cortex_config() → {
///     llm_backend: string,        // "offline" | "ollama" | "claude" | "openai-compat"
///     llm_model: string | null,
///     llm_endpoint: string | null,
///     embedding_backend: string,  // "stub" | "ollama"
///     embedding_model: string | null,
///     embedding_endpoint: string | null,
///     mcp_auto_commit: bool
/// }
/// ```
#[derive(Debug)]
pub struct CortexConfigTool;

impl CortexConfigTool {
    /// Construct the tool. No store access required.
    #[must_use]
    pub fn new() -> Self {
        Self
    }
}

impl Default for CortexConfigTool {
    fn default() -> Self {
        Self::new()
    }
}

impl ToolHandler for CortexConfigTool {
    fn name(&self) -> &'static str {
        "cortex_config"
    }

    fn gate_set(&self) -> &'static [GateId] {
        &[GateId::HealthRead]
    }

    fn call(&self, _params: Value) -> Result<Value, ToolError> {
        // ── LLM backend ───────────────────────────────────────────────────────
        // Priority: env vars > TOML config file > immutable defaults.
        let env_backend = std::env::var("CORTEX_LLM_BACKEND")
            .ok()
            .filter(|s| !s.is_empty());
        let env_model = std::env::var("CORTEX_LLM_MODEL")
            .ok()
            .filter(|s| !s.is_empty());
        let env_endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
            .ok()
            .filter(|s| !s.is_empty());

        let config_file = read_config_file_raw();

        let backend_str = env_backend
            .as_deref()
            .or_else(|| config_file.as_deref().and_then(extract_llm_backend))
            .unwrap_or("offline");

        let (llm_backend, llm_model, llm_endpoint) = match backend_str {
            "ollama" => {
                let model =
                    env_model.or_else(|| config_file.as_deref().and_then(extract_ollama_model));
                let endpoint = env_endpoint
                    .or_else(|| config_file.as_deref().and_then(extract_ollama_endpoint))
                    .unwrap_or_else(|| "http://localhost:11434".to_string());
                ("ollama", model, Some(endpoint))
            }
            "claude" => {
                let model =
                    env_model.or_else(|| config_file.as_deref().and_then(extract_claude_model));
                ("claude", model, None)
            }
            "openai-compat" => ("openai-compat", env_model, env_endpoint),
            _ => ("offline", None, None),
        };

        // ── Embedding backend ─────────────────────────────────────────────────
        // Priority: CORTEX_EMBEDDING_BACKEND env var, then stub fallback.
        let emb_env_backend = std::env::var("CORTEX_EMBEDDING_BACKEND")
            .ok()
            .filter(|s| !s.is_empty());
        let emb_env_model = std::env::var("CORTEX_EMBEDDING_MODEL")
            .ok()
            .filter(|s| !s.is_empty());
        let emb_env_endpoint = std::env::var("CORTEX_EMBEDDING_ENDPOINT")
            .ok()
            .filter(|s| !s.is_empty());

        let emb_backend_str = emb_env_backend.as_deref().unwrap_or("stub");

        let (embedding_backend, embedding_model, embedding_endpoint) = match emb_backend_str {
            "ollama" => {
                let endpoint = emb_env_endpoint
                    .or_else(|| config_file.as_deref().and_then(extract_ollama_endpoint))
                    .unwrap_or_else(|| "http://localhost:11434".to_string());
                ("ollama", emb_env_model, Some(endpoint))
            }
            _ => ("stub", None, None),
        };

        // ── mcp_auto_commit ───────────────────────────────────────────────────
        // Priority: env var CORTEX_MCP_AUTO_COMMIT=1 (highest), then
        // [mcp] auto_commit = true in the config file, then false (safe default).
        // This mirrors McpConfig::resolve() in cortex-cli (CR2-issue9).
        let mcp_auto_commit = if std::env::var("CORTEX_MCP_AUTO_COMMIT").as_deref() == Ok("1") {
            true
        } else {
            // Check the config file for [mcp] auto_commit = true.
            config_file
                .as_deref()
                .and_then(extract_mcp_auto_commit)
                .unwrap_or(false)
        };

        Ok(json!({
            "llm_backend":         llm_backend,
            "llm_model":           llm_model,
            "llm_endpoint":        llm_endpoint,
            "embedding_backend":   embedding_backend,
            "embedding_model":     embedding_model,
            "embedding_endpoint":  embedding_endpoint,
            "mcp_auto_commit":     mcp_auto_commit,
        }))
    }
}

/// Read the Cortex config file to a raw string if accessible.
///
/// Resolution order: `CORTEX_CONFIG` env var → `XDG_CONFIG_HOME/cortex/config.toml`.
/// Returns `None` when the file is absent or unreadable; never fails.
fn read_config_file_raw() -> Option<String> {
    let path: std::path::PathBuf =
        if let Some(explicit) = std::env::var_os("CORTEX_CONFIG").filter(|v| !v.is_empty()) {
            std::path::PathBuf::from(explicit)
        } else if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
            std::path::PathBuf::from(xdg)
                .join("cortex")
                .join("config.toml")
        } else {
            // Fallback: $HOME/.config/cortex/config.toml (XDG default).
            let home = std::env::var_os("HOME")
                .or_else(|| std::env::var_os("USERPROFILE"))
                .filter(|v| !v.is_empty())?;
            std::path::PathBuf::from(home)
                .join(".config")
                .join("cortex")
                .join("config.toml")
        };

    std::fs::read_to_string(path).ok()
}

/// Extract `[llm].backend` from raw TOML text without pulling in the full
/// TOML deserializer (the cortex-mcp crate does not list `toml` as a
/// dependency). We use `serde_json` via a TOML-to-JSON approach: but since
/// `toml` is not in our dep tree, we do minimal string scanning instead.
///
/// This is intentionally conservative: if the value cannot be parsed reliably,
/// we return `None` and let the env-var / default take over.
fn extract_llm_backend(raw: &str) -> Option<&str> {
    extract_toml_string_value(raw, "backend", "[llm]", "[")
}

fn extract_ollama_model(raw: &str) -> Option<String> {
    extract_toml_string_value(raw, "model", "[llm.ollama]", "[").map(ToOwned::to_owned)
}

fn extract_ollama_endpoint(raw: &str) -> Option<String> {
    extract_toml_string_value(raw, "endpoint", "[llm.ollama]", "[").map(ToOwned::to_owned)
}

fn extract_claude_model(raw: &str) -> Option<String> {
    extract_toml_string_value(raw, "model", "[llm.claude]", "[").map(ToOwned::to_owned)
}

/// Extract `[mcp].auto_commit` from raw TOML text.
///
/// Returns `Some(true)` only when the key is explicitly `true`; any other
/// value (absent, `false`, or unrecognised) returns `None` so the caller
/// can fall back to `false`.
fn extract_mcp_auto_commit(raw: &str) -> Option<bool> {
    let section_start = raw.find("[mcp]")?;
    let after_section = &raw[section_start + "[mcp]".len()..];

    for line in after_section.lines() {
        let trimmed = line.trim();
        // Stop at the next TOML section heading.
        if trimmed.starts_with('[') {
            break;
        }
        // Match `auto_commit = true` or `auto_commit = false`.
        if let Some(rest) = trimmed.strip_prefix("auto_commit") {
            let rest = rest.trim_start();
            if let Some(rest) = rest.strip_prefix('=') {
                let val = rest.trim();
                if val == "true" {
                    return Some(true);
                } else if val == "false" {
                    return Some(false);
                }
            }
        }
    }
    None
}

/// Minimal TOML key-value extractor.
///
/// Searches for `section_header` in `raw`, then within the subsequent
/// lines (up to the first line starting with `stop_at`) looks for
/// `key = "value"` and returns the unescaped value string.
///
/// Only handles simple quoted-string values (the typical config shape).
fn extract_toml_string_value<'a>(
    raw: &'a str,
    key: &str,
    section_header: &str,
    stop_at: &str,
) -> Option<&'a str> {
    let section_start = raw.find(section_header)?;
    let after_section = &raw[section_start + section_header.len()..];

    for line in after_section.lines() {
        let trimmed = line.trim();
        // Stop at the next TOML section heading.
        if trimmed.starts_with(stop_at) && trimmed != section_header.trim_start_matches('[') {
            break;
        }
        // Match `key = "value"` (with optional spaces).
        if let Some(rest) = trimmed.strip_prefix(key) {
            let rest = rest.trim_start();
            if let Some(rest) = rest.strip_prefix('=') {
                let rest = rest.trim();
                if let Some(inner) = rest.strip_prefix('"') {
                    if let Some(end) = inner.find('"') {
                        return Some(&inner[..end]);
                    }
                }
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_tool() -> CortexConfigTool {
        CortexConfigTool::new()
    }

    #[test]
    fn gate_set_declares_health_read() {
        let tool = make_tool();
        assert!(
            tool.gate_set().contains(&GateId::HealthRead),
            "gate_set must include HealthRead"
        );
    }

    #[test]
    fn tool_name_matches_schema_contract() {
        let tool = make_tool();
        assert_eq!(tool.name(), "cortex_config");
    }

    #[test]
    fn call_returns_all_required_keys() {
        let tool = make_tool();
        let result = tool
            .call(serde_json::Value::Null)
            .expect("call must succeed");

        for key in &[
            "llm_backend",
            "llm_model",
            "llm_endpoint",
            "embedding_backend",
            "embedding_model",
            "embedding_endpoint",
            "mcp_auto_commit",
        ] {
            assert!(result.get(key).is_some(), "missing key: {key}");
        }

        assert!(
            result["mcp_auto_commit"].is_boolean(),
            "mcp_auto_commit must be a bool"
        );
    }

    #[test]
    fn toml_extractor_parses_simple_value() {
        let toml = "[llm]\nbackend = \"ollama\"\n\n[llm.ollama]\nendpoint = \"http://localhost:11434\"\nmodel = \"llama3\"\n";
        assert_eq!(
            extract_toml_string_value(toml, "backend", "[llm]", "["),
            Some("ollama")
        );
        assert_eq!(
            extract_toml_string_value(toml, "model", "[llm.ollama]", "["),
            Some("llama3")
        );
        assert_eq!(
            extract_toml_string_value(toml, "endpoint", "[llm.ollama]", "["),
            Some("http://localhost:11434")
        );
    }

    #[test]
    fn toml_extractor_returns_none_for_missing_section() {
        let toml = "[other]\nkey = \"val\"\n";
        assert!(extract_toml_string_value(toml, "backend", "[llm]", "[").is_none());
    }

    #[test]
    fn mcp_auto_commit_extractor_detects_true() {
        let toml = "[mcp]\nauto_commit = true\n";
        assert_eq!(extract_mcp_auto_commit(toml), Some(true));
    }

    #[test]
    fn mcp_auto_commit_extractor_detects_false() {
        let toml = "[mcp]\nauto_commit = false\n";
        assert_eq!(extract_mcp_auto_commit(toml), Some(false));
    }

    #[test]
    fn mcp_auto_commit_extractor_returns_none_when_absent() {
        let toml = "[llm]\nbackend = \"ollama\"\n";
        assert_eq!(extract_mcp_auto_commit(toml), None);
    }

    #[test]
    fn mcp_auto_commit_extractor_stops_at_next_section() {
        // auto_commit appears AFTER [other], not in [mcp] — must not be found.
        let toml = "[mcp]\n[other]\nauto_commit = true\n";
        assert_eq!(extract_mcp_auto_commit(toml), None);
    }
}