cortex_mcp/tools/
config_status.rs1use serde_json::{json, Value};
12
13use crate::{GateId, ToolError, ToolHandler};
14
15#[derive(Debug)]
30pub struct CortexConfigTool;
31
32impl CortexConfigTool {
33 #[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 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 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 let mcp_auto_commit =
122 if std::env::var("CORTEX_MCP_AUTO_COMMIT").as_deref() == Ok("1") {
123 true
124 } else {
125 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
144fn 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 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
170fn 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
193fn 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 if trimmed.starts_with('[') {
206 break;
207 }
208 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
224fn 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 if trimmed.starts_with(stop_at) && trimmed != section_header.trim_start_matches('[') {
244 break;
245 }
246 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 let toml = "[mcp]\n[other]\nauto_commit = true\n";
355 assert_eq!(extract_mcp_auto_commit(toml), None);
356 }
357}