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 = if std::env::var("CORTEX_MCP_AUTO_COMMIT").as_deref() == Ok("1") {
122 true
123 } else {
124 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
143fn 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 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
169fn 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
192fn 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 if trimmed.starts_with('[') {
205 break;
206 }
207 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
223fn 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 if trimmed.starts_with(stop_at) && trimmed != section_header.trim_start_matches('[') {
243 break;
244 }
245 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 let toml = "[mcp]\n[other]\nauto_commit = true\n";
354 assert_eq!(extract_mcp_auto_commit(toml), None);
355 }
356}