mod config;
mod embedder;
mod ort_fetch;
mod server;
use std::error::Error;
use std::path::PathBuf;
use rmcp::transport::stdio;
use rmcp::ServiceExt;
use topodb::Db;
use crate::config::Config;
use crate::server::TopoServer;
fn default_model_cache_dir() -> PathBuf {
match std::env::var_os("HOME") {
Some(home) if !home.is_empty() => PathBuf::from(home)
.join(".cache")
.join("topodb")
.join("models"),
_ => PathBuf::from(".topodb-models"),
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let config = Config::from_args(std::env::args().skip(1))?;
if let Some(parent) = config.db_path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
return Err(format!(
"database parent directory does not exist: {}",
parent.display()
)
.into());
}
}
let budget_ms = match std::env::var("TOPODB_LOCK_WAIT_MS") {
Err(_) => 3000,
Ok(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
eprintln!("topodb-mcp: ignoring unparseable TOPODB_LOCK_WAIT_MS={raw:?}; using 3000");
3000
}),
};
let db = topodb_json::open_with_busy_retry(budget_ms, || {
match &config.spec {
Some(spec) => Db::open_with(&config.db_path, spec.clone()),
None if config.db_path.exists() => {
let db = Db::open_stored(&config.db_path)?;
let persisted = db.index_spec();
let upgraded = topodb_json::upgraded_spec(persisted.clone());
if upgraded != persisted {
drop(db);
Db::open_with(&config.db_path, upgraded)
} else {
Ok(db)
}
}
None => Db::open_with(&config.db_path, config::default_spec()),
}
})?;
let embedder = match config.embeddings.as_deref() {
Some(s) if s.eq_ignore_ascii_case("off") => embedder::Embedder::disabled(),
Some(s) if s.eq_ignore_ascii_case("auto") => embedder::Embedder::start(
None,
config
.model_dir
.clone()
.unwrap_or_else(default_model_cache_dir),
!config.no_ort_download,
),
other => embedder::Embedder::start(
other.map(str::to_string),
config
.model_dir
.clone()
.unwrap_or_else(default_model_cache_dir),
!config.no_ort_download,
),
};
{
let db = db.clone();
let embedder = embedder.clone();
std::thread::spawn(move || {
use topodb::{Op, PropValue};
use topodb_json::{
ALIAS_LABEL, ALIAS_NAME_PROP, ENTITY_LABEL, ENTITY_NAME_PROP, MEMORY_CONTENT_PROP,
MEMORY_LABEL,
};
loop {
std::thread::sleep(std::time::Duration::from_secs(2));
if embedder.status() != crate::embedder::EmbedderStatus::Ready {
if embedder.status() == crate::embedder::EmbedderStatus::Failed
|| embedder.status() == crate::embedder::EmbedderStatus::Off
{
return; }
continue; }
let model = embedder.model_name();
let mut batched = 0usize;
let Ok(events) = db.ops_since(1) else { return };
let mut embedded: std::collections::HashSet<topodb::NodeId> = Default::default();
let mut removed: std::collections::HashSet<topodb::NodeId> = Default::default();
let mut candidates: Vec<(topodb::NodeId, String)> = Vec::new();
for ev in &events {
match ev.op.as_ref() {
Op::SetEmbedding { id, model: m, .. } if *m == model => {
embedded.insert(*id);
}
Op::RemoveNode { id } => {
removed.insert(*id);
}
Op::CreateNode {
id, label, props, ..
} => {
let text = match label.as_str() {
l if l == MEMORY_LABEL => props.get(MEMORY_CONTENT_PROP),
l if l == ENTITY_LABEL => props.get(ENTITY_NAME_PROP),
l if l == ALIAS_LABEL => props.get(ALIAS_NAME_PROP),
_ => None,
};
if let Some(PropValue::Str(t)) = text {
candidates.push((*id, t.clone()));
}
}
_ => {}
}
}
for (id, text) in candidates {
if embedded.contains(&id) || removed.contains(&id) {
continue;
}
let Some(vector) = embedder.embed(&text) else {
continue;
};
let _ = db.submit(vec![Op::SetEmbedding {
id,
model: model.clone(),
vector,
}]);
batched += 1;
if batched.is_multiple_of(16) {
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
return; }
});
}
let server = TopoServer::new(db, &config, embedder);
let service = server.serve(stdio()).await?;
service.waiting().await?;
Ok(())
}