use kowalski_core::config::Config;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
pub fn mcp_config_path(config_path: Option<&str>) -> PathBuf {
config_path
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("config.toml"))
}
pub fn load_kowalski_config_for_serve(path: &Path) -> Result<Config, Box<dyn std::error::Error>> {
if !path.exists() {
log::warn!(
"No config at {} — using defaults (Ollama localhost; add config.toml for MCP/tools)",
path.display()
);
return Ok(Config::default());
}
let raw = fs::read_to_string(path)?;
Ok(toml::from_str(&raw)?)
}
#[derive(Debug, Clone, Serialize)]
pub struct McpServerPublic {
pub name: String,
pub url: String,
pub transport: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct McpPingResult {
pub name: String,
pub url: String,
pub transport: String,
pub ok: bool,
pub tool_count: Option<usize>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorJson {
pub cli_version: String,
pub ollama: OllamaProbeJson,
pub llm: LlmDoctorJson,
pub operator: DoctorOperatorJson,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorOperatorJson {
pub mcp_servers_configured: usize,
pub postgres_memory_configured: bool,
pub config_divergence: Vec<String>,
pub mcp_streamable_session_note: &'static str,
}
pub fn config_divergence_lines(c: &Config) -> Vec<String> {
let d = Config::default();
let mut v = Vec::new();
if c.ollama.model != d.ollama.model {
v.push("ollama.model".into());
}
if c.ollama.host != d.ollama.host || c.ollama.port != d.ollama.port {
v.push("ollama host/port".into());
}
if c.memory.database_url.is_some() {
v.push("memory.database_url set".into());
}
if c.memory.episodic_path != d.memory.episodic_path {
v.push("memory.episodic_path".into());
}
if !c.mcp.servers.is_empty() {
v.push(format!("mcp.servers: {}", c.mcp.servers.len()));
}
if c.llm.provider != d.llm.provider {
v.push("llm.provider".into());
}
if c.llm.openai_api_base != d.llm.openai_api_base {
v.push("llm.openai_api_base".into());
}
v
}
#[derive(Debug, Clone, Serialize)]
pub struct LlmDoctorJson {
pub provider: String,
pub model: String,
pub openai_api_base: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaProbeJson {
pub url: String,
pub ok: bool,
pub detail: String,
}
pub fn list_mcp_servers_public(
path: &Path,
) -> Result<Vec<McpServerPublic>, Box<dyn std::error::Error>> {
use crate::config::load_mcp_config_from_file;
let mcp = load_mcp_config_from_file(path)?;
Ok(mcp
.servers
.iter()
.map(|s| McpServerPublic {
name: s.name.clone(),
url: s.url.clone(),
transport: match s.transport {
kowalski_core::config::McpTransport::Http => "http".to_string(),
kowalski_core::config::McpTransport::Sse => "sse".to_string(),
kowalski_core::config::McpTransport::Stdio => "stdio".to_string(),
},
})
.collect())
}
pub async fn mcp_ping_results(
path: &Path,
) -> Result<Vec<McpPingResult>, Box<dyn std::error::Error>> {
use crate::config::load_mcp_config_from_file;
let mcp = load_mcp_config_from_file(path)?;
let mut out = Vec::with_capacity(mcp.servers.len());
for server in &mcp.servers {
let transport = match server.transport {
kowalski_core::config::McpTransport::Http => "http",
kowalski_core::config::McpTransport::Sse => "sse",
kowalski_core::config::McpTransport::Stdio => "stdio",
};
let url_display = if server.url.trim().is_empty() {
server.command.join(" ")
} else {
server.url.clone()
};
let result: Result<
Vec<kowalski_core::mcp::types::McpToolDescription>,
kowalski_core::KowalskiError,
> = if matches!(server.transport, kowalski_core::config::McpTransport::Stdio) {
match kowalski_core::McpStdioClient::connect(server).await {
Ok(c) => c.list_tools().await,
Err(e) => Err(e),
}
} else {
match kowalski_core::mcp::McpClient::connect_server(server).await {
Ok(c) => c.list_tools().await,
Err(e) => Err(e),
}
};
match result {
Ok(tools) => out.push(McpPingResult {
name: server.name.clone(),
url: url_display,
transport: transport.to_string(),
ok: true,
tool_count: Some(tools.len()),
error: None,
}),
Err(e) => out.push(McpPingResult {
name: server.name.clone(),
url: url_display,
transport: transport.to_string(),
ok: false,
tool_count: None,
error: Some(e.to_string()),
}),
}
}
Ok(out)
}
async fn probe_ollama_tags(base: &str) -> OllamaProbeJson {
let base = base.trim_end_matches('/');
let tags_url = format!("{}/api/tags", base);
match reqwest::get(&tags_url).await {
Ok(r) => {
if r.status().is_success() {
OllamaProbeJson {
url: tags_url,
ok: true,
detail: format!("HTTP {}", r.status()),
}
} else {
OllamaProbeJson {
url: tags_url,
ok: false,
detail: format!("HTTP {}", r.status()),
}
}
}
Err(e) => OllamaProbeJson {
url: tags_url,
ok: false,
detail: e.to_string(),
},
}
}
pub async fn doctor_json(ollama_base: Option<String>, config: Option<&Config>) -> DoctorJson {
let base = ollama_base.unwrap_or_else(|| "http://127.0.0.1:11434".to_string());
let ollama = probe_ollama_tags(&base).await;
let c = config.cloned().unwrap_or_default();
let llm = LlmDoctorJson {
provider: c.llm.provider.clone(),
model: c.ollama.model.clone(),
openai_api_base: c.llm.openai_api_base.clone(),
};
let operator = DoctorOperatorJson {
mcp_servers_configured: c.mcp.servers.len(),
postgres_memory_configured: kowalski_core::config::memory_uses_postgres(&c.memory),
config_divergence: config_divergence_lines(&c),
mcp_streamable_session_note: "After initialize, Streamable HTTP MCP session ids are available via `McpClient::session_id()` (and `McpClient::shutdown()` clears the session).",
};
DoctorJson {
cli_version: env!("CARGO_PKG_VERSION").to_string(),
ollama,
llm,
operator,
}
}
pub fn run_config_check(path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let raw = fs::read_to_string(path)?;
let _toml: toml::Value = toml::from_str(&raw)?;
println!("OK — valid TOML ({})", path.display());
match toml::from_str::<Config>(&raw) {
Ok(c) => {
println!("OK — parses as Kowalski core `Config`");
println!(
" ollama: {}:{} / model {}",
c.ollama.host, c.ollama.port, c.ollama.model
);
println!(" memory: episodic_path = {}", c.memory.episodic_path);
if let Some(ref u) = c.memory.database_url {
println!(" memory.database_url = {}", u);
} else {
println!(" memory.database_url = (unset — Tier 2 SQLite file only)");
}
println!(" mcp servers: {}", c.mcp.servers.len());
println!(
" llm: provider = {}, model = {}",
c.llm.provider, c.ollama.model
);
if let Some(ref b) = c.llm.openai_api_base {
println!(" llm.openai_api_base = {}", b);
}
}
Err(e) => {
println!("Note — not a full core `Config` (fix or use partial TOML only):");
println!(" {}", e);
}
}
Ok(())
}
pub async fn run_db_migrate(
url: Option<String>,
config: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
let resolved = if let Some(u) = url {
u
} else {
let path = config
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("config.toml"));
let raw = fs::read_to_string(&path)?;
let v: toml::Value = toml::from_str(&raw)?;
let url = v
.get("memory")
.and_then(|m| m.get("database_url"))
.and_then(|x| x.as_str())
.ok_or("No memory.database_url in config and no --url")?;
url.to_string()
};
println!("Running migrations for {}", resolved);
kowalski_core::db::run_migrations(&resolved).await?;
println!("Done.");
Ok(())
}
pub async fn run_doctor(ollama_base: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
let config = load_optional_config_default_path();
let j = doctor_json(ollama_base, config.as_ref()).await;
println!("kowalski-cli {}", j.cli_version);
println!(
"LLM: provider = {}, model = {}",
j.llm.provider, j.llm.model
);
if let Some(ref b) = j.llm.openai_api_base {
println!("LLM: openai_api_base = {}", b);
}
if j.ollama.ok {
println!("Ollama: OK — {}", j.ollama.url);
} else if j.ollama.detail.starts_with("HTTP ") {
println!("Ollama: {} — {}", j.ollama.detail, j.ollama.url);
} else {
println!(
"Ollama: unreachable ({}) — {}",
j.ollama.url, j.ollama.detail
);
}
println!(
"Operator: MCP servers in config = {}, postgres memory URL = {}",
j.operator.mcp_servers_configured, j.operator.postgres_memory_configured
);
if !j.operator.config_divergence.is_empty() {
println!(
"Config vs defaults: {}",
j.operator.config_divergence.join(", ")
);
}
Ok(())
}
fn load_optional_config_default_path() -> Option<Config> {
let path = PathBuf::from("config.toml");
if !path.exists() {
return None;
}
let raw = fs::read_to_string(&path).ok()?;
toml::from_str(&raw).ok()
}