Skip to main content

cortex_mcp/tools/
models_list.rs

1//! `cortex_models_list` MCP tool handler.
2//!
3//! Queries the configured LLM backend for available models. Mirrors the intent
4//! of `cortex models list` — calls Ollama `/api/tags` or returns a no-backend
5//! notice — without depending on `cortex-cli`.
6//!
7//! Gate: [`GateId::HealthRead`].
8//! Tier: supervised — logs at `tracing::info!` before each remote call.
9
10use serde_json::{json, Value};
11
12use crate::{GateId, ToolError, ToolHandler};
13
14/// MCP tool: `cortex_models_list`.
15///
16/// Schema:
17/// ```text
18/// cortex_models_list(backend?: "ollama" | "openai-compat")
19///   → { backend: string, endpoint: string, models: [{name, size_bytes?, configured_for: [string]}] }
20///      | { models: [], note: string }  // when no backend is configured
21/// ```
22#[derive(Debug)]
23pub struct CortexModelsListTool;
24
25impl CortexModelsListTool {
26    /// Construct the tool. No store access required.
27    #[must_use]
28    pub fn new() -> Self {
29        Self
30    }
31}
32
33impl Default for CortexModelsListTool {
34    fn default() -> Self {
35        Self::new()
36    }
37}
38
39impl ToolHandler for CortexModelsListTool {
40    fn name(&self) -> &'static str {
41        "cortex_models_list"
42    }
43
44    fn gate_set(&self) -> &'static [GateId] {
45        &[GateId::HealthRead]
46    }
47
48    fn call(&self, params: Value) -> Result<Value, ToolError> {
49        tracing::info!("cortex_models_list called via MCP");
50
51        // ── Resolve which backend to query ────────────────────────────────────
52        // Explicit `backend` param > CORTEX_LLM_BACKEND env var > offline.
53        let param_backend = params
54            .get("backend")
55            .and_then(|v| v.as_str())
56            .map(ToOwned::to_owned);
57
58        let env_backend = std::env::var("CORTEX_LLM_BACKEND")
59            .ok()
60            .filter(|s| !s.is_empty());
61
62        let effective_backend = param_backend
63            .as_deref()
64            .or(env_backend.as_deref())
65            .unwrap_or("offline");
66
67        match effective_backend {
68            "ollama" => query_ollama(),
69            "openai-compat" => query_openai_compat(),
70            _ => {
71                // No backend configured or explicit "offline" — not an error.
72                Ok(json!({
73                    "backend":  "offline",
74                    "endpoint": "",
75                    "models":   [],
76                    "note":     "No LLM backend configured. Set CORTEX_LLM_BACKEND=ollama or configure [llm] in cortex.toml.",
77                }))
78            }
79        }
80    }
81}
82
83/// Query Ollama `/api/tags` and return the model list.
84fn query_ollama() -> Result<Value, ToolError> {
85    let endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
86        .ok()
87        .filter(|s| !s.is_empty())
88        .unwrap_or_else(|| "http://localhost:11434".to_string());
89
90    let url = format!("{endpoint}/api/tags");
91
92    tracing::info!(
93        endpoint = %endpoint,
94        "cortex_models_list: querying Ollama /api/tags"
95    );
96
97    let response = ureq::get(&url).call().map_err(|err| {
98        ToolError::Internal(format!(
99            "cortex_models_list: Ollama request to {url} failed: {err}"
100        ))
101    })?;
102
103    let body: Value = response.into_json().map_err(|err| {
104        ToolError::Internal(format!(
105            "cortex_models_list: failed to parse Ollama /api/tags response: {err}"
106        ))
107    })?;
108
109    // Ollama response shape: { "models": [{ "name": "...", "size": <bytes>, ... }] }
110    let models_raw = body
111        .get("models")
112        .and_then(|v| v.as_array())
113        .cloned()
114        .unwrap_or_default();
115
116    let configured_model = std::env::var("CORTEX_LLM_MODEL")
117        .ok()
118        .filter(|s| !s.is_empty());
119
120    let models: Vec<Value> = models_raw
121        .iter()
122        .filter_map(|entry| {
123            let name = entry.get("name")?.as_str()?.to_owned();
124            let size_bytes = entry.get("size").and_then(|v| v.as_u64());
125            let mut configured_for: Vec<&str> = Vec::new();
126            if configured_model.as_deref() == Some(&name) {
127                configured_for.push("llm");
128            }
129            let mut m = json!({
130                "name":           name,
131                "configured_for": configured_for,
132            });
133            if let Some(sz) = size_bytes {
134                m["size_bytes"] = json!(sz);
135            }
136            Some(m)
137        })
138        .collect();
139
140    Ok(json!({
141        "backend":  "ollama",
142        "endpoint": endpoint,
143        "models":   models,
144    }))
145}
146
147/// Query an OpenAI-compat `/v1/models` endpoint.
148fn query_openai_compat() -> Result<Value, ToolError> {
149    let endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
150        .ok()
151        .filter(|s| !s.is_empty())
152        .ok_or_else(|| {
153            ToolError::InvalidParams(
154                "cortex_models_list: backend=openai-compat requires CORTEX_LLM_ENDPOINT".into(),
155            )
156        })?;
157
158    let url = format!("{endpoint}/v1/models");
159
160    tracing::info!(
161        endpoint = %endpoint,
162        "cortex_models_list: querying OpenAI-compat /v1/models"
163    );
164
165    let response = ureq::get(&url).call().map_err(|err| {
166        ToolError::Internal(format!(
167            "cortex_models_list: OpenAI-compat request to {url} failed: {err}"
168        ))
169    })?;
170
171    let body: Value = response.into_json().map_err(|err| {
172        ToolError::Internal(format!(
173            "cortex_models_list: failed to parse OpenAI-compat /v1/models response: {err}"
174        ))
175    })?;
176
177    // OpenAI response shape: { "data": [{ "id": "...", ... }] }
178    let models_raw = body
179        .get("data")
180        .and_then(|v| v.as_array())
181        .cloned()
182        .unwrap_or_default();
183
184    let configured_model = std::env::var("CORTEX_LLM_MODEL")
185        .ok()
186        .filter(|s| !s.is_empty());
187
188    let models: Vec<Value> = models_raw
189        .iter()
190        .filter_map(|entry| {
191            let name = entry.get("id")?.as_str()?.to_owned();
192            let mut configured_for: Vec<&str> = Vec::new();
193            if configured_model.as_deref() == Some(&name) {
194                configured_for.push("llm");
195            }
196            Some(json!({
197                "name":           name,
198                "configured_for": configured_for,
199            }))
200        })
201        .collect();
202
203    Ok(json!({
204        "backend":  "openai-compat",
205        "endpoint": endpoint,
206        "models":   models,
207    }))
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn make_tool() -> CortexModelsListTool {
215        CortexModelsListTool::new()
216    }
217
218    #[test]
219    fn gate_set_declares_health_read() {
220        let tool = make_tool();
221        assert!(
222            tool.gate_set().contains(&GateId::HealthRead),
223            "gate_set must include HealthRead"
224        );
225    }
226
227    #[test]
228    fn tool_name_matches_schema_contract() {
229        let tool = make_tool();
230        assert_eq!(tool.name(), "cortex_models_list");
231    }
232
233    #[test]
234    fn no_backend_configured_returns_empty_models_not_error() {
235        // With CORTEX_LLM_BACKEND unset (or "offline"), the tool must return
236        // a no-backend notice rather than an error.
237        let tool = make_tool();
238
239        // Force offline by passing explicit backend param.
240        let result = tool
241            .call(serde_json::json!({ "backend": "offline" }))
242            .expect("offline backend must not error");
243
244        assert!(
245            result.get("models").and_then(|v| v.as_array()).is_some(),
246            "models array must be present"
247        );
248        let models = result["models"].as_array().unwrap();
249        assert!(
250            models.is_empty(),
251            "offline backend must return empty models"
252        );
253        assert!(
254            result.get("note").is_some(),
255            "note must be present for offline backend"
256        );
257    }
258
259    #[test]
260    fn null_params_defaults_to_env_or_offline() {
261        let tool = make_tool();
262        // Must not panic on null params.
263        let result = tool.call(serde_json::Value::Null);
264        assert!(
265            result.is_ok(),
266            "null params must not error (offline fallback): {result:?}"
267        );
268    }
269}