use serde_json::{json, Value};
use crate::{GateId, ToolError, ToolHandler};
#[derive(Debug)]
pub struct CortexModelsListTool;
impl CortexModelsListTool {
#[must_use]
pub fn new() -> Self {
Self
}
}
impl Default for CortexModelsListTool {
fn default() -> Self {
Self::new()
}
}
impl ToolHandler for CortexModelsListTool {
fn name(&self) -> &'static str {
"cortex_models_list"
}
fn gate_set(&self) -> &'static [GateId] {
&[GateId::HealthRead]
}
fn call(&self, params: Value) -> Result<Value, ToolError> {
tracing::info!("cortex_models_list called via MCP");
let param_backend = params
.get("backend")
.and_then(|v| v.as_str())
.map(ToOwned::to_owned);
let env_backend = std::env::var("CORTEX_LLM_BACKEND")
.ok()
.filter(|s| !s.is_empty());
let effective_backend = param_backend
.as_deref()
.or(env_backend.as_deref())
.unwrap_or("offline");
match effective_backend {
"ollama" => query_ollama(),
"openai-compat" => query_openai_compat(),
_ => {
Ok(json!({
"backend": "offline",
"endpoint": "",
"models": [],
"note": "No LLM backend configured. Set CORTEX_LLM_BACKEND=ollama or configure [llm] in cortex.toml.",
}))
}
}
}
}
fn query_ollama() -> Result<Value, ToolError> {
let endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
.ok()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "http://localhost:11434".to_string());
let url = format!("{endpoint}/api/tags");
tracing::info!(
endpoint = %endpoint,
"cortex_models_list: querying Ollama /api/tags"
);
let response = ureq::get(&url).call().map_err(|err| {
ToolError::Internal(format!(
"cortex_models_list: Ollama request to {url} failed: {err}"
))
})?;
let body: Value = response.into_json().map_err(|err| {
ToolError::Internal(format!(
"cortex_models_list: failed to parse Ollama /api/tags response: {err}"
))
})?;
let models_raw = body
.get("models")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let configured_model = std::env::var("CORTEX_LLM_MODEL")
.ok()
.filter(|s| !s.is_empty());
let models: Vec<Value> = models_raw
.iter()
.filter_map(|entry| {
let name = entry.get("name")?.as_str()?.to_owned();
let size_bytes = entry.get("size").and_then(|v| v.as_u64());
let mut configured_for: Vec<&str> = Vec::new();
if configured_model.as_deref() == Some(&name) {
configured_for.push("llm");
}
let mut m = json!({
"name": name,
"configured_for": configured_for,
});
if let Some(sz) = size_bytes {
m["size_bytes"] = json!(sz);
}
Some(m)
})
.collect();
Ok(json!({
"backend": "ollama",
"endpoint": endpoint,
"models": models,
}))
}
fn query_openai_compat() -> Result<Value, ToolError> {
let endpoint = std::env::var("CORTEX_LLM_ENDPOINT")
.ok()
.filter(|s| !s.is_empty())
.ok_or_else(|| {
ToolError::InvalidParams(
"cortex_models_list: backend=openai-compat requires CORTEX_LLM_ENDPOINT".into(),
)
})?;
let url = format!("{endpoint}/v1/models");
tracing::info!(
endpoint = %endpoint,
"cortex_models_list: querying OpenAI-compat /v1/models"
);
let response = ureq::get(&url).call().map_err(|err| {
ToolError::Internal(format!(
"cortex_models_list: OpenAI-compat request to {url} failed: {err}"
))
})?;
let body: Value = response.into_json().map_err(|err| {
ToolError::Internal(format!(
"cortex_models_list: failed to parse OpenAI-compat /v1/models response: {err}"
))
})?;
let models_raw = body
.get("data")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let configured_model = std::env::var("CORTEX_LLM_MODEL")
.ok()
.filter(|s| !s.is_empty());
let models: Vec<Value> = models_raw
.iter()
.filter_map(|entry| {
let name = entry.get("id")?.as_str()?.to_owned();
let mut configured_for: Vec<&str> = Vec::new();
if configured_model.as_deref() == Some(&name) {
configured_for.push("llm");
}
Some(json!({
"name": name,
"configured_for": configured_for,
}))
})
.collect();
Ok(json!({
"backend": "openai-compat",
"endpoint": endpoint,
"models": models,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool() -> CortexModelsListTool {
CortexModelsListTool::new()
}
#[test]
fn gate_set_declares_health_read() {
let tool = make_tool();
assert!(
tool.gate_set().contains(&GateId::HealthRead),
"gate_set must include HealthRead"
);
}
#[test]
fn tool_name_matches_schema_contract() {
let tool = make_tool();
assert_eq!(tool.name(), "cortex_models_list");
}
#[test]
fn no_backend_configured_returns_empty_models_not_error() {
let tool = make_tool();
let result = tool
.call(serde_json::json!({ "backend": "offline" }))
.expect("offline backend must not error");
assert!(
result.get("models").and_then(|v| v.as_array()).is_some(),
"models array must be present"
);
let models = result["models"].as_array().unwrap();
assert!(
models.is_empty(),
"offline backend must return empty models"
);
assert!(
result.get("note").is_some(),
"note must be present for offline backend"
);
}
#[test]
fn null_params_defaults_to_env_or_offline() {
let tool = make_tool();
let result = tool.call(serde_json::Value::Null);
assert!(
result.is_ok(),
"null params must not error (offline fallback): {result:?}"
);
}
}