use std::time::Duration;
use rmcp::ServiceExt;
use velesdb_memory::mcp::McpServer;
use velesdb_memory::{DynEmbedder, HashEmbedder, MemoryService, NativeStore, DEFAULT_DIMENSION};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = std::env::args().collect();
if args
.get(1)
.is_some_and(|arg| arg == "--version" || arg == "-V")
{
println!("velesdb-memory {}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
#[cfg(unix)]
let original_parent = std::os::unix::process::parent_id();
#[cfg(not(unix))]
let original_parent = 0_u32;
let store_path = std::env::var("VELESDB_MEMORY_PATH").unwrap_or_else(|_| default_store_path());
let http_bind = requested_http_bind(&args);
let embedder = build_embedder()?;
let service = open_store_with_actionable_lock_error(&store_path, embedder)?;
let server = apply_ingest_roots(apply_default_ttl(build_server(service)?)?)?;
tokio::runtime::Runtime::new()?.block_on(async move {
match http_bind {
#[cfg(feature = "http")]
Some(request) => serve_http(server, request).await,
#[cfg(not(feature = "http"))]
Some(_never) => unreachable!(
"requested_http_bind only returns Some when built with --features http"
),
None => {
spawn_orphan_watchdog(original_parent);
let running = server
.serve((tokio::io::stdin(), tokio::io::stdout()))
.await?;
running.waiting().await?;
Ok::<(), Box<dyn std::error::Error>>(())
}
}
})
}
#[cfg(feature = "http")]
struct HttpServeRequest {
bind_addr: String,
insecure: bool,
}
#[cfg(feature = "http")]
fn requested_http_bind(args: &[String]) -> Option<HttpServeRequest> {
let http_flag = args.iter().any(|arg| arg == "--http");
let http_env = std::env::var("VELESDB_MEMORY_HTTP").as_deref() == Ok("1");
if !http_flag && !http_env {
return None;
}
let port_override = args
.iter()
.position(|arg| arg == "--http-port")
.and_then(|flag_index| args.get(flag_index + 1));
let default_bind = std::env::var("VELESDB_MEMORY_HTTP_BIND")
.unwrap_or_else(|_| velesdb_memory::http::DEFAULT_HTTP_BIND.to_owned());
let bind_addr = match port_override {
Some(port) => match default_bind.rsplit_once(':') {
Some((host, _existing_port)) => format!("{host}:{port}"),
None => format!("127.0.0.1:{port}"),
},
None => default_bind,
};
if !is_loopback_host(&bind_addr)
&& std::env::var("VELESDB_MEMORY_HTTP_ALLOW_REMOTE").as_deref() != Ok("1")
{
eprintln!(
"[velesdb-memory] refusing to bind the HTTP transport to '{bind_addr}': it is not a \
loopback address, and the streamable-HTTP transport has no authentication — anyone \
who can reach that socket gets full read/write access to the store. Set \
VELESDB_MEMORY_HTTP_ALLOW_REMOTE=1 to override (put an authenticating reverse proxy \
in front first)."
);
std::process::exit(1);
}
let insecure_flag = args.iter().any(|arg| arg == "--http-insecure");
let insecure_env = std::env::var("VELESDB_MEMORY_HTTP_INSECURE").as_deref() == Ok("1");
let insecure = insecure_flag || insecure_env;
Some(HttpServeRequest {
bind_addr,
insecure,
})
}
#[cfg(feature = "http")]
fn is_loopback_host(bind_addr: &str) -> bool {
let host = bind_addr
.rsplit_once(':')
.map_or(bind_addr, |(host, _port)| host)
.trim_start_matches('[')
.trim_end_matches(']');
host.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
#[cfg(not(feature = "http"))]
fn requested_http_bind(args: &[String]) -> Option<String> {
let http_flag = args.iter().any(|arg| arg == "--http");
let http_env = std::env::var("VELESDB_MEMORY_HTTP").as_deref() == Ok("1");
if http_flag || http_env {
eprintln!(
"[velesdb-memory] --http / VELESDB_MEMORY_HTTP=1 requires a binary built with \
`--features http` (e.g. `cargo install velesdb-memory --features http`) — \
this binary was built without it"
);
std::process::exit(1);
}
None
}
#[cfg(feature = "http")]
async fn serve_http(
server: McpServer,
request: HttpServeRequest,
) -> Result<(), Box<dyn std::error::Error>> {
let HttpServeRequest {
bind_addr,
insecure,
} = request;
let ct = tokio_util::sync::CancellationToken::new();
let app = velesdb_memory::http::router(server, ct.child_token());
let listener = tokio::net::TcpListener::bind(&bind_addr).await?;
let ctrl_c_ct = ct.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
ctrl_c_ct.cancel();
}
});
if insecure {
eprintln!(
"[velesdb-memory] WARNING: --http-insecure / VELESDB_MEMORY_HTTP_INSECURE=1 is set — \
serving PLAIN HTTP (no TLS) on http://{bind_addr}/mcp. Every request is readable by \
anyone who can reach that socket (loopback-only by default — see \
VELESDB_MEMORY_HTTP_ALLOW_REMOTE above). Use this only for local debugging, or when \
a trusted TLS-terminating proxy already sits in front."
);
eprintln!("[velesdb-memory] HTTP server listening on http://{bind_addr}/mcp");
axum::serve(listener, app)
.with_graceful_shutdown(async move { ct.cancelled_owned().await })
.await?;
return Ok(());
}
let tls_dir = velesdb_memory::tls::tls_dir_from_env();
let material = velesdb_memory::tls::ensure_tls_material(&tls_dir)?;
let acceptor = velesdb_memory::tls::tls_acceptor_from_material(&material)?;
eprintln!("[velesdb-memory] HTTPS server listening on https://{bind_addr}/mcp");
eprintln!(
"[velesdb-memory] Local CA: {} — a client only needs to trust this once (see \
./scripts/install-memory-daemon.sh, which does this automatically on macOS); every \
future leaf certificate this daemon issues is signed by the same CA and is trusted \
automatically after that.",
material.ca_cert_path.display()
);
velesdb_memory::http::serve_tls(app, listener, acceptor, ct).await;
Ok(())
}
#[cfg(unix)]
const ORPHAN_CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
#[cfg(unix)]
fn spawn_orphan_watchdog(original_parent: u32) {
use std::os::unix::process::parent_id;
tokio::spawn(async move {
loop {
tokio::time::sleep(ORPHAN_CHECK_INTERVAL).await;
let current_parent = parent_id();
if current_parent != original_parent {
eprintln!(
"[velesdb-memory] parent process (pid {original_parent}) is gone \
(now reparented under pid {current_parent}) — exiting to release \
the store lock rather than leak a zombie session (#1448)"
);
std::process::exit(0);
}
}
});
}
#[cfg(not(unix))]
fn spawn_orphan_watchdog(_original_parent: u32) {}
const LOCK_RETRY_ATTEMPTS: u32 = 3;
const LOCK_RETRY_DELAY: Duration = Duration::from_millis(500);
fn open_store_with_actionable_lock_error(
store_path: &str,
embedder: DynEmbedder,
) -> Result<MemoryService<DynEmbedder>, Box<dyn std::error::Error>> {
use velesdb_memory::MemoryError;
let dimension = embedder.dimension();
let mut last_locked_path: Option<String> = None;
for attempt in 0..LOCK_RETRY_ATTEMPTS {
match NativeStore::open(store_path, dimension) {
Ok(store) => return Ok(MemoryService::with_store(store, embedder)),
Err(MemoryError::Storage(velesdb_core::Error::DatabaseLocked(locked_path))) => {
last_locked_path = Some(locked_path);
if attempt + 1 < LOCK_RETRY_ATTEMPTS {
std::thread::sleep(LOCK_RETRY_DELAY);
}
}
Err(other) => return Err(other.into()),
}
}
let locked_path = last_locked_path.unwrap_or_else(|| store_path.to_owned());
eprintln!(
"[velesdb-memory] another velesdb-memory process holds {locked_path} — \
kill it (pkill velesdb-memory) or point VELESDB_MEMORY_PATH elsewhere"
);
std::process::exit(1);
}
fn default_store_path() -> String {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.filter(|h| !h.is_empty());
match home {
Some(home) => std::path::Path::new(&home)
.join(".velesdb-memory")
.to_string_lossy()
.into_owned(),
None => "./velesdb-memory-store".to_owned(),
}
}
fn apply_default_ttl(server: McpServer) -> Result<McpServer, Box<dyn std::error::Error>> {
match std::env::var("VELESDB_MEMORY_DEFAULT_TTL") {
Ok(raw) => {
let ttl_seconds: u64 = raw.trim().parse().map_err(|_| {
format!(
"VELESDB_MEMORY_DEFAULT_TTL must be a non-negative integer (seconds), got '{raw}'"
)
})?;
Ok(server.with_default_ttl(ttl_seconds))
}
Err(_) => Ok(server),
}
}
#[cfg(feature = "context")]
fn apply_ingest_roots(server: McpServer) -> Result<McpServer, Box<dyn std::error::Error>> {
match std::env::var("VELESDB_MEMORY_INGEST_ROOTS") {
Ok(raw) if !raw.trim().is_empty() => {
let roots = velesdb_memory::context::IngestRoots::parse(&raw)?;
Ok(server.with_ingest_roots(roots))
}
_ => Ok(server),
}
}
#[cfg(not(feature = "context"))]
#[allow(clippy::unnecessary_wraps)]
fn apply_ingest_roots(server: McpServer) -> Result<McpServer, Box<dyn std::error::Error>> {
Ok(server)
}
#[cfg(feature = "extract")]
fn build_server(
service: MemoryService<DynEmbedder>,
) -> Result<McpServer, Box<dyn std::error::Error>> {
let server = McpServer::new(service);
match std::env::var("VELESDB_MEMORY_EXTRACTOR").as_deref() {
Ok("ollama") => Ok(server.with_extractor(build_ollama_extractor()?)),
Ok("none") | Err(_) => Ok(server),
Ok(other) => {
Err(format!("unknown VELESDB_MEMORY_EXTRACTOR '{other}' (expected 'ollama')").into())
}
}
}
#[cfg(not(feature = "extract"))]
#[allow(clippy::unnecessary_wraps)]
fn build_server(
service: MemoryService<DynEmbedder>,
) -> Result<McpServer, Box<dyn std::error::Error>> {
Ok(McpServer::new(service))
}
#[cfg(feature = "extract")]
fn build_ollama_extractor() -> Result<velesdb_memory::DynExtractor, Box<dyn std::error::Error>> {
use std::sync::Arc;
use velesdb_memory::extract::DEFAULT_OLLAMA_URL;
use velesdb_memory::OllamaExtractor;
let url = std::env::var("VELESDB_MEMORY_EXTRACTOR_URL")
.unwrap_or_else(|_| DEFAULT_OLLAMA_URL.to_owned());
let model = std::env::var("VELESDB_MEMORY_EXTRACTOR_MODEL").map_err(|_| {
"VELESDB_MEMORY_EXTRACTOR=ollama requires VELESDB_MEMORY_EXTRACTOR_MODEL \
(e.g. qwen3.6:35b-mlx)"
})?;
Ok(Arc::new(OllamaExtractor::new(url, model)))
}
fn build_embedder() -> Result<DynEmbedder, Box<dyn std::error::Error>> {
match std::env::var("VELESDB_MEMORY_EMBEDDER").as_deref() {
Ok("ollama") => build_ollama_embedder(),
Ok("hash") | Err(_) => {
warn_hash_embedder_not_semantic();
Ok(Box::new(HashEmbedder::new(DEFAULT_DIMENSION)))
}
Ok(other) => Err(format!(
"unknown VELESDB_MEMORY_EMBEDDER '{other}' (expected 'hash' or 'ollama')"
)
.into()),
}
}
fn warn_hash_embedder_not_semantic() {
if std::env::var_os("VELESDB_MEMORY_QUIET").is_some() {
return;
}
eprintln!(
"[velesdb-memory] Using the default 'hash' embedder: deterministic and \
fully offline, but NOT semantic — recall matches surface form, not meaning. \
For real semantic recall, run an Ollama build with \
VELESDB_MEMORY_EMBEDDER=ollama (see crates/velesdb-memory/README.md). \
Set VELESDB_MEMORY_QUIET=1 to silence this notice."
);
}
#[cfg(feature = "ollama")]
fn build_ollama_embedder() -> Result<DynEmbedder, Box<dyn std::error::Error>> {
use velesdb_memory::{OllamaEmbedder, DEFAULT_OLLAMA_MODEL, DEFAULT_OLLAMA_URL};
let url = std::env::var("VELESDB_MEMORY_OLLAMA_URL")
.unwrap_or_else(|_| DEFAULT_OLLAMA_URL.to_owned());
let model = std::env::var("VELESDB_MEMORY_OLLAMA_MODEL")
.unwrap_or_else(|_| DEFAULT_OLLAMA_MODEL.to_owned());
Ok(Box::new(OllamaEmbedder::new(url, model)?))
}
#[cfg(not(feature = "ollama"))]
fn build_ollama_embedder() -> Result<DynEmbedder, Box<dyn std::error::Error>> {
Err("the 'ollama' embedder requires building with `--features ollama`".into())
}
#[cfg(all(test, feature = "http"))]
mod tests {
use super::is_loopback_host;
#[test]
fn loopback_v4_and_v6_are_recognized() {
assert!(is_loopback_host("127.0.0.1:18090"));
assert!(is_loopback_host("127.0.0.5:18090"));
assert!(is_loopback_host("[::1]:18090"));
}
#[test]
fn non_loopback_hosts_are_rejected() {
assert!(!is_loopback_host("0.0.0.0:18090"));
assert!(!is_loopback_host("192.168.1.10:18090"));
assert!(!is_loopback_host("[::]:18090"));
assert!(!is_loopback_host("mcp.example.com:18090"));
}
}