Skip to main content

cortex_mcp/tools/
config_status.rs

1//! `cortex_config` MCP tool handler.
2//!
3//! Returns effective LLM and embedding backend configuration without exposing
4//! any secrets. The response tells the AI whether `--live-reflect` will work
5//! before calling `cortex_session_close`, and whether `cortex_memory_embed`
6//! will use a real embedding backend or the deterministic stub.
7//!
8//! Gate: [`GateId::HealthRead`].
9//! Tier: session — read-only, no confirmation needed.
10
11use serde_json::{json, Value};
12
13use crate::{GateId, ToolError, ToolHandler};
14
15/// MCP tool: `cortex_config`.
16///
17/// Schema:
18/// ```text
19/// cortex_config() → {
20///     llm_backend: string,        // "offline" | "ollama" | "claude" | "openai-compat"
21///     llm_model: string | null,
22///     llm_endpoint: string | null,
23///     embedding_backend: string,  // "stub" | "ollama"
24///     embedding_model: string | null,
25///     embedding_endpoint: string | null,
26///     mcp_auto_commit: bool
27/// }
28/// ```
29#[derive(Debug)]
30pub struct CortexConfigTool;
31
32impl CortexConfigTool {
33    /// Construct the tool. No store access required.
34    #[must_use]
35    pub fn new() -> Self {
36        Self
37    }
38}
39
40impl Default for CortexConfigTool {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl ToolHandler for CortexConfigTool {
47    fn name(&self) -> &'static str {
48        "cortex_config"
49    }
50
51    fn gate_set(&self) -> &'static [GateId] {
52        &[GateId::HealthRead]
53    }
54
55    fn call(&self, _params: Value) -> Result<Value, ToolError> {
56        // ── LLM backend ───────────────────────────────────────────────────────
57        // Priority: env vars > TOML config file > immutable defaults.
58        let env_backend = std::env::var("CORTEX_LLM_BACKEND")
59            .ok()
60            .filter(|s| !s.is_empty());
61        let env_model = std::env::var("CORTEX_LLM_MODEL")
62            .ok()
63            .filter(|s| !s.is_empty());
64        let env_endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
65            .ok()
66            .filter(|s| !s.is_empty());
67
68        let config_file = read_config_file_raw();
69
70        let backend_str = env_backend
71            .as_deref()
72            .or_else(|| config_file.as_deref().and_then(extract_llm_backend))
73            .unwrap_or("offline");
74
75        let (llm_backend, llm_model, llm_endpoint) = match backend_str {
76            "ollama" => {
77                let model =
78                    env_model.or_else(|| config_file.as_deref().and_then(extract_ollama_model));
79                let endpoint = env_endpoint
80                    .or_else(|| config_file.as_deref().and_then(extract_ollama_endpoint))
81                    .unwrap_or_else(|| "http://localhost:11434".to_string());
82                ("ollama", model, Some(endpoint))
83            }
84            "claude" => {
85                let model =
86                    env_model.or_else(|| config_file.as_deref().and_then(extract_claude_model));
87                ("claude", model, None)
88            }
89            "openai-compat" => ("openai-compat", env_model, env_endpoint),
90            _ => ("offline", None, None),
91        };
92
93        // ── Embedding backend ─────────────────────────────────────────────────
94        // Priority: CORTEX_EMBEDDING_BACKEND env var, then stub fallback.
95        let emb_env_backend = std::env::var("CORTEX_EMBEDDING_BACKEND")
96            .ok()
97            .filter(|s| !s.is_empty());
98        let emb_env_model = std::env::var("CORTEX_EMBEDDING_MODEL")
99            .ok()
100            .filter(|s| !s.is_empty());
101        let emb_env_endpoint = std::env::var("CORTEX_EMBEDDING_ENDPOINT")
102            .ok()
103            .filter(|s| !s.is_empty());
104
105        let emb_backend_str = emb_env_backend.as_deref().unwrap_or("stub");
106
107        let (embedding_backend, embedding_model, embedding_endpoint) = match emb_backend_str {
108            "ollama" => {
109                let endpoint = emb_env_endpoint
110                    .or_else(|| config_file.as_deref().and_then(extract_ollama_endpoint))
111                    .unwrap_or_else(|| "http://localhost:11434".to_string());
112                ("ollama", emb_env_model, Some(endpoint))
113            }
114            _ => ("stub", None, None),
115        };
116
117        // ── mcp_auto_commit ───────────────────────────────────────────────────
118        // Priority: env var CORTEX_MCP_AUTO_COMMIT=1 (highest), then
119        // [mcp] auto_commit = true in the config file, then false (safe default).
120        // This mirrors McpConfig::resolve() in cortex-cli (CR2-issue9).
121        let mcp_auto_commit = if std::env::var("CORTEX_MCP_AUTO_COMMIT").as_deref() == Ok("1") {
122            true
123        } else {
124            // Check the config file for [mcp] auto_commit = true.
125            config_file
126                .as_deref()
127                .and_then(extract_mcp_auto_commit)
128                .unwrap_or(false)
129        };
130
131        Ok(json!({
132            "llm_backend":         llm_backend,
133            "llm_model":           llm_model,
134            "llm_endpoint":        llm_endpoint,
135            "embedding_backend":   embedding_backend,
136            "embedding_model":     embedding_model,
137            "embedding_endpoint":  embedding_endpoint,
138            "mcp_auto_commit":     mcp_auto_commit,
139        }))
140    }
141}
142
143/// Read the Cortex config file to a raw string if accessible.
144///
145/// Resolution order: `CORTEX_CONFIG` env var → `XDG_CONFIG_HOME/cortex/config.toml`.
146/// Returns `None` when the file is absent or unreadable; never fails.
147fn read_config_file_raw() -> Option<String> {
148    let path: std::path::PathBuf =
149        if let Some(explicit) = std::env::var_os("CORTEX_CONFIG").filter(|v| !v.is_empty()) {
150            std::path::PathBuf::from(explicit)
151        } else if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
152            std::path::PathBuf::from(xdg)
153                .join("cortex")
154                .join("config.toml")
155        } else {
156            // Fallback: $HOME/.config/cortex/config.toml (XDG default).
157            let home = std::env::var_os("HOME")
158                .or_else(|| std::env::var_os("USERPROFILE"))
159                .filter(|v| !v.is_empty())?;
160            std::path::PathBuf::from(home)
161                .join(".config")
162                .join("cortex")
163                .join("config.toml")
164        };
165
166    std::fs::read_to_string(path).ok()
167}
168
169/// Extract `[llm].backend` from raw TOML text without pulling in the full
170/// TOML deserializer (the cortex-mcp crate does not list `toml` as a
171/// dependency). We use `serde_json` via a TOML-to-JSON approach: but since
172/// `toml` is not in our dep tree, we do minimal string scanning instead.
173///
174/// This is intentionally conservative: if the value cannot be parsed reliably,
175/// we return `None` and let the env-var / default take over.
176fn extract_llm_backend(raw: &str) -> Option<&str> {
177    extract_toml_string_value(raw, "backend", "[llm]", "[")
178}
179
180fn extract_ollama_model(raw: &str) -> Option<String> {
181    extract_toml_string_value(raw, "model", "[llm.ollama]", "[").map(ToOwned::to_owned)
182}
183
184fn extract_ollama_endpoint(raw: &str) -> Option<String> {
185    extract_toml_string_value(raw, "endpoint", "[llm.ollama]", "[").map(ToOwned::to_owned)
186}
187
188fn extract_claude_model(raw: &str) -> Option<String> {
189    extract_toml_string_value(raw, "model", "[llm.claude]", "[").map(ToOwned::to_owned)
190}
191
192/// Extract `[mcp].auto_commit` from raw TOML text.
193///
194/// Returns `Some(true)` only when the key is explicitly `true`; any other
195/// value (absent, `false`, or unrecognised) returns `None` so the caller
196/// can fall back to `false`.
197fn extract_mcp_auto_commit(raw: &str) -> Option<bool> {
198    let section_start = raw.find("[mcp]")?;
199    let after_section = &raw[section_start + "[mcp]".len()..];
200
201    for line in after_section.lines() {
202        let trimmed = line.trim();
203        // Stop at the next TOML section heading.
204        if trimmed.starts_with('[') {
205            break;
206        }
207        // Match `auto_commit = true` or `auto_commit = false`.
208        if let Some(rest) = trimmed.strip_prefix("auto_commit") {
209            let rest = rest.trim_start();
210            if let Some(rest) = rest.strip_prefix('=') {
211                let val = rest.trim();
212                if val == "true" {
213                    return Some(true);
214                } else if val == "false" {
215                    return Some(false);
216                }
217            }
218        }
219    }
220    None
221}
222
223/// Minimal TOML key-value extractor.
224///
225/// Searches for `section_header` in `raw`, then within the subsequent
226/// lines (up to the first line starting with `stop_at`) looks for
227/// `key = "value"` and returns the unescaped value string.
228///
229/// Only handles simple quoted-string values (the typical config shape).
230fn extract_toml_string_value<'a>(
231    raw: &'a str,
232    key: &str,
233    section_header: &str,
234    stop_at: &str,
235) -> Option<&'a str> {
236    let section_start = raw.find(section_header)?;
237    let after_section = &raw[section_start + section_header.len()..];
238
239    for line in after_section.lines() {
240        let trimmed = line.trim();
241        // Stop at the next TOML section heading.
242        if trimmed.starts_with(stop_at) && trimmed != section_header.trim_start_matches('[') {
243            break;
244        }
245        // Match `key = "value"` (with optional spaces).
246        if let Some(rest) = trimmed.strip_prefix(key) {
247            let rest = rest.trim_start();
248            if let Some(rest) = rest.strip_prefix('=') {
249                let rest = rest.trim();
250                if let Some(inner) = rest.strip_prefix('"') {
251                    if let Some(end) = inner.find('"') {
252                        return Some(&inner[..end]);
253                    }
254                }
255            }
256        }
257    }
258    None
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn make_tool() -> CortexConfigTool {
266        CortexConfigTool::new()
267    }
268
269    #[test]
270    fn gate_set_declares_health_read() {
271        let tool = make_tool();
272        assert!(
273            tool.gate_set().contains(&GateId::HealthRead),
274            "gate_set must include HealthRead"
275        );
276    }
277
278    #[test]
279    fn tool_name_matches_schema_contract() {
280        let tool = make_tool();
281        assert_eq!(tool.name(), "cortex_config");
282    }
283
284    #[test]
285    fn call_returns_all_required_keys() {
286        let tool = make_tool();
287        let result = tool
288            .call(serde_json::Value::Null)
289            .expect("call must succeed");
290
291        for key in &[
292            "llm_backend",
293            "llm_model",
294            "llm_endpoint",
295            "embedding_backend",
296            "embedding_model",
297            "embedding_endpoint",
298            "mcp_auto_commit",
299        ] {
300            assert!(result.get(key).is_some(), "missing key: {key}");
301        }
302
303        assert!(
304            result["mcp_auto_commit"].is_boolean(),
305            "mcp_auto_commit must be a bool"
306        );
307    }
308
309    #[test]
310    fn toml_extractor_parses_simple_value() {
311        let toml = "[llm]\nbackend = \"ollama\"\n\n[llm.ollama]\nendpoint = \"http://localhost:11434\"\nmodel = \"llama3\"\n";
312        assert_eq!(
313            extract_toml_string_value(toml, "backend", "[llm]", "["),
314            Some("ollama")
315        );
316        assert_eq!(
317            extract_toml_string_value(toml, "model", "[llm.ollama]", "["),
318            Some("llama3")
319        );
320        assert_eq!(
321            extract_toml_string_value(toml, "endpoint", "[llm.ollama]", "["),
322            Some("http://localhost:11434")
323        );
324    }
325
326    #[test]
327    fn toml_extractor_returns_none_for_missing_section() {
328        let toml = "[other]\nkey = \"val\"\n";
329        assert!(extract_toml_string_value(toml, "backend", "[llm]", "[").is_none());
330    }
331
332    #[test]
333    fn mcp_auto_commit_extractor_detects_true() {
334        let toml = "[mcp]\nauto_commit = true\n";
335        assert_eq!(extract_mcp_auto_commit(toml), Some(true));
336    }
337
338    #[test]
339    fn mcp_auto_commit_extractor_detects_false() {
340        let toml = "[mcp]\nauto_commit = false\n";
341        assert_eq!(extract_mcp_auto_commit(toml), Some(false));
342    }
343
344    #[test]
345    fn mcp_auto_commit_extractor_returns_none_when_absent() {
346        let toml = "[llm]\nbackend = \"ollama\"\n";
347        assert_eq!(extract_mcp_auto_commit(toml), None);
348    }
349
350    #[test]
351    fn mcp_auto_commit_extractor_stops_at_next_section() {
352        // auto_commit appears AFTER [other], not in [mcp] — must not be found.
353        let toml = "[mcp]\n[other]\nauto_commit = true\n";
354        assert_eq!(extract_mcp_auto_commit(toml), None);
355    }
356}