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 =
122            if std::env::var("CORTEX_MCP_AUTO_COMMIT").as_deref() == Ok("1") {
123                true
124            } else {
125                // Check the config file for [mcp] auto_commit = true.
126                config_file
127                    .as_deref()
128                    .and_then(|raw| extract_mcp_auto_commit(raw))
129                    .unwrap_or(false)
130            };
131
132        Ok(json!({
133            "llm_backend":         llm_backend,
134            "llm_model":           llm_model,
135            "llm_endpoint":        llm_endpoint,
136            "embedding_backend":   embedding_backend,
137            "embedding_model":     embedding_model,
138            "embedding_endpoint":  embedding_endpoint,
139            "mcp_auto_commit":     mcp_auto_commit,
140        }))
141    }
142}
143
144/// Read the Cortex config file to a raw string if accessible.
145///
146/// Resolution order: `CORTEX_CONFIG` env var → `XDG_CONFIG_HOME/cortex/config.toml`.
147/// Returns `None` when the file is absent or unreadable; never fails.
148fn read_config_file_raw() -> Option<String> {
149    let path: std::path::PathBuf =
150        if let Some(explicit) = std::env::var_os("CORTEX_CONFIG").filter(|v| !v.is_empty()) {
151            std::path::PathBuf::from(explicit)
152        } else if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
153            std::path::PathBuf::from(xdg)
154                .join("cortex")
155                .join("config.toml")
156        } else {
157            // Fallback: $HOME/.config/cortex/config.toml (XDG default).
158            let home = std::env::var_os("HOME")
159                .or_else(|| std::env::var_os("USERPROFILE"))
160                .filter(|v| !v.is_empty())?;
161            std::path::PathBuf::from(home)
162                .join(".config")
163                .join("cortex")
164                .join("config.toml")
165        };
166
167    std::fs::read_to_string(path).ok()
168}
169
170/// Extract `[llm].backend` from raw TOML text without pulling in the full
171/// TOML deserializer (the cortex-mcp crate does not list `toml` as a
172/// dependency). We use `serde_json` via a TOML-to-JSON approach: but since
173/// `toml` is not in our dep tree, we do minimal string scanning instead.
174///
175/// This is intentionally conservative: if the value cannot be parsed reliably,
176/// we return `None` and let the env-var / default take over.
177fn extract_llm_backend(raw: &str) -> Option<&str> {
178    extract_toml_string_value(raw, "backend", "[llm]", "[")
179}
180
181fn extract_ollama_model(raw: &str) -> Option<String> {
182    extract_toml_string_value(raw, "model", "[llm.ollama]", "[").map(ToOwned::to_owned)
183}
184
185fn extract_ollama_endpoint(raw: &str) -> Option<String> {
186    extract_toml_string_value(raw, "endpoint", "[llm.ollama]", "[").map(ToOwned::to_owned)
187}
188
189fn extract_claude_model(raw: &str) -> Option<String> {
190    extract_toml_string_value(raw, "model", "[llm.claude]", "[").map(ToOwned::to_owned)
191}
192
193/// Extract `[mcp].auto_commit` from raw TOML text.
194///
195/// Returns `Some(true)` only when the key is explicitly `true`; any other
196/// value (absent, `false`, or unrecognised) returns `None` so the caller
197/// can fall back to `false`.
198fn extract_mcp_auto_commit(raw: &str) -> Option<bool> {
199    let section_start = raw.find("[mcp]")?;
200    let after_section = &raw[section_start + "[mcp]".len()..];
201
202    for line in after_section.lines() {
203        let trimmed = line.trim();
204        // Stop at the next TOML section heading.
205        if trimmed.starts_with('[') {
206            break;
207        }
208        // Match `auto_commit = true` or `auto_commit = false`.
209        if let Some(rest) = trimmed.strip_prefix("auto_commit") {
210            let rest = rest.trim_start();
211            if let Some(rest) = rest.strip_prefix('=') {
212                let val = rest.trim();
213                if val == "true" {
214                    return Some(true);
215                } else if val == "false" {
216                    return Some(false);
217                }
218            }
219        }
220    }
221    None
222}
223
224/// Minimal TOML key-value extractor.
225///
226/// Searches for `section_header` in `raw`, then within the subsequent
227/// lines (up to the first line starting with `stop_at`) looks for
228/// `key = "value"` and returns the unescaped value string.
229///
230/// Only handles simple quoted-string values (the typical config shape).
231fn extract_toml_string_value<'a>(
232    raw: &'a str,
233    key: &str,
234    section_header: &str,
235    stop_at: &str,
236) -> Option<&'a str> {
237    let section_start = raw.find(section_header)?;
238    let after_section = &raw[section_start + section_header.len()..];
239
240    for line in after_section.lines() {
241        let trimmed = line.trim();
242        // Stop at the next TOML section heading.
243        if trimmed.starts_with(stop_at) && trimmed != section_header.trim_start_matches('[') {
244            break;
245        }
246        // Match `key = "value"` (with optional spaces).
247        if let Some(rest) = trimmed.strip_prefix(key) {
248            let rest = rest.trim_start();
249            if let Some(rest) = rest.strip_prefix('=') {
250                let rest = rest.trim();
251                if let Some(inner) = rest.strip_prefix('"') {
252                    if let Some(end) = inner.find('"') {
253                        return Some(&inner[..end]);
254                    }
255                }
256            }
257        }
258    }
259    None
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn make_tool() -> CortexConfigTool {
267        CortexConfigTool::new()
268    }
269
270    #[test]
271    fn gate_set_declares_health_read() {
272        let tool = make_tool();
273        assert!(
274            tool.gate_set().contains(&GateId::HealthRead),
275            "gate_set must include HealthRead"
276        );
277    }
278
279    #[test]
280    fn tool_name_matches_schema_contract() {
281        let tool = make_tool();
282        assert_eq!(tool.name(), "cortex_config");
283    }
284
285    #[test]
286    fn call_returns_all_required_keys() {
287        let tool = make_tool();
288        let result = tool
289            .call(serde_json::Value::Null)
290            .expect("call must succeed");
291
292        for key in &[
293            "llm_backend",
294            "llm_model",
295            "llm_endpoint",
296            "embedding_backend",
297            "embedding_model",
298            "embedding_endpoint",
299            "mcp_auto_commit",
300        ] {
301            assert!(result.get(key).is_some(), "missing key: {key}");
302        }
303
304        assert!(
305            result["mcp_auto_commit"].is_boolean(),
306            "mcp_auto_commit must be a bool"
307        );
308    }
309
310    #[test]
311    fn toml_extractor_parses_simple_value() {
312        let toml = "[llm]\nbackend = \"ollama\"\n\n[llm.ollama]\nendpoint = \"http://localhost:11434\"\nmodel = \"llama3\"\n";
313        assert_eq!(
314            extract_toml_string_value(toml, "backend", "[llm]", "["),
315            Some("ollama")
316        );
317        assert_eq!(
318            extract_toml_string_value(toml, "model", "[llm.ollama]", "["),
319            Some("llama3")
320        );
321        assert_eq!(
322            extract_toml_string_value(toml, "endpoint", "[llm.ollama]", "["),
323            Some("http://localhost:11434")
324        );
325    }
326
327    #[test]
328    fn toml_extractor_returns_none_for_missing_section() {
329        let toml = "[other]\nkey = \"val\"\n";
330        assert!(extract_toml_string_value(toml, "backend", "[llm]", "[").is_none());
331    }
332
333    #[test]
334    fn mcp_auto_commit_extractor_detects_true() {
335        let toml = "[mcp]\nauto_commit = true\n";
336        assert_eq!(extract_mcp_auto_commit(toml), Some(true));
337    }
338
339    #[test]
340    fn mcp_auto_commit_extractor_detects_false() {
341        let toml = "[mcp]\nauto_commit = false\n";
342        assert_eq!(extract_mcp_auto_commit(toml), Some(false));
343    }
344
345    #[test]
346    fn mcp_auto_commit_extractor_returns_none_when_absent() {
347        let toml = "[llm]\nbackend = \"ollama\"\n";
348        assert_eq!(extract_mcp_auto_commit(toml), None);
349    }
350
351    #[test]
352    fn mcp_auto_commit_extractor_stops_at_next_section() {
353        // auto_commit appears AFTER [other], not in [mcp] — must not be found.
354        let toml = "[mcp]\n[other]\nauto_commit = true\n";
355        assert_eq!(extract_mcp_auto_commit(toml), None);
356    }
357}