crtx-mcp 0.1.2

MCP stdio JSON-RPC 2.0 server for Cortex — tool dispatch, ToolHandler trait, gate wiring (ADR 0045).
Documentation
//! `cortex_models_list` MCP tool handler.
//!
//! Queries the configured LLM backend for available models. Mirrors the intent
//! of `cortex models list` — calls Ollama `/api/tags` or returns a no-backend
//! notice — without depending on `cortex-cli`.
//!
//! Gate: [`GateId::HealthRead`].
//! Tier: supervised — logs at `tracing::info!` before each remote call.

use serde_json::{json, Value};

use crate::{GateId, ToolError, ToolHandler};

/// MCP tool: `cortex_models_list`.
///
/// Schema:
/// ```text
/// cortex_models_list(backend?: "ollama" | "openai-compat")
///   → { backend: string, endpoint: string, models: [{name, size_bytes?, configured_for: [string]}] }
///      | { models: [], note: string }  // when no backend is configured
/// ```
#[derive(Debug)]
pub struct CortexModelsListTool;

impl CortexModelsListTool {
    /// Construct the tool. No store access required.
    #[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");

        // ── Resolve which backend to query ────────────────────────────────────
        // Explicit `backend` param > CORTEX_LLM_BACKEND env var > offline.
        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(),
            _ => {
                // No backend configured or explicit "offline" — not an error.
                Ok(json!({
                    "backend":  "offline",
                    "endpoint": "",
                    "models":   [],
                    "note":     "No LLM backend configured. Set CORTEX_LLM_BACKEND=ollama or configure [llm] in cortex.toml.",
                }))
            }
        }
    }
}

/// Query Ollama `/api/tags` and return the model list.
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}"
        ))
    })?;

    // Ollama response shape: { "models": [{ "name": "...", "size": <bytes>, ... }] }
    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,
    }))
}

/// Query an OpenAI-compat `/v1/models` endpoint.
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}"
        ))
    })?;

    // OpenAI response shape: { "data": [{ "id": "...", ... }] }
    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() {
        // With CORTEX_LLM_BACKEND unset (or "offline"), the tool must return
        // a no-backend notice rather than an error.
        let tool = make_tool();

        // Force offline by passing explicit backend param.
        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();
        // Must not panic on null params.
        let result = tool.call(serde_json::Value::Null);
        assert!(
            result.is_ok(),
            "null params must not error (offline fallback): {result:?}"
        );
    }
}