cortex_mcp/tools/
models_list.rs1use serde_json::{json, Value};
11
12use crate::{GateId, ToolError, ToolHandler};
13
14#[derive(Debug)]
23pub struct CortexModelsListTool;
24
25impl CortexModelsListTool {
26 #[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 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 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
83fn 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 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
147fn 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 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 let tool = make_tool();
238
239 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 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}