use clap::Parser;
use rmcp::ServiceExt;
#[cfg(not(all(feature = "stable", not(feature = "full"))))]
use std::path::Path;
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
use semantic_memory_mcp::bridge::{self, EmbedderBackend};
#[cfg(not(all(feature = "stable", not(feature = "full"))))]
use semantic_memory_mcp::http_server;
use semantic_memory_mcp::server;
#[derive(Parser, Debug)]
#[command(name = "semantic-memory-mcp", version)]
#[command(about = "MCP server for semantic-memory — local-first knowledge management")]
struct Cli {
#[arg(long)]
memory_dir: String,
#[arg(long, default_value = "candle")]
embedder: EmbedderBackend,
#[arg(long)]
embedding_url: Option<String>,
#[arg(long)]
embedding_model: Option<String>,
#[arg(long)]
embedding_dims: Option<usize>,
#[arg(long)]
http_port: Option<u16>,
#[arg(long)]
http_auth_token: Option<String>,
#[arg(long)]
http_auth_token_file: Option<PathBuf>,
#[arg(long)]
http_only: bool,
#[arg(long)]
turbo_quant: bool,
#[arg(long)]
turbo_quant_bits: Option<u8>,
#[arg(long)]
turbo_quant_projections: Option<usize>,
#[cfg_attr(
all(feature = "stable", not(feature = "full")),
arg(long, default_value = "stable")
)]
#[cfg_attr(
not(all(feature = "stable", not(feature = "full"))),
arg(long, default_value = "lean")
)]
tool_profile: String,
}
#[cfg(not(all(feature = "stable", not(feature = "full"))))]
fn normalize_http_auth_token(raw: &str, source: &str) -> anyhow::Result<String> {
let token = raw.trim();
if token.is_empty() {
anyhow::bail!("HTTP authorization token from {source} is empty");
}
if token.chars().any(char::is_whitespace) {
anyhow::bail!("HTTP authorization token from {source} contains whitespace");
}
Ok(token.to_string())
}
#[cfg(not(all(feature = "stable", not(feature = "full"))))]
fn resolve_http_auth_token(
explicit: Option<&str>,
token_file: Option<&Path>,
) -> anyhow::Result<Option<String>> {
if let Some(token) = explicit {
if !token.is_empty() {
return normalize_http_auth_token(token, "--http-auth-token").map(Some);
}
}
if let Some(path) = token_file {
let raw = std::fs::read_to_string(path)
.map_err(|error| anyhow::anyhow!("failed to read {}: {error}", path.display()))?;
return normalize_http_auth_token(&raw, &path.display().to_string()).map(Some);
}
Ok(None)
}
fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env())
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
eprintln!("semantic-memory-mcp starting...");
eprintln!(" memory_dir: {}", cli.memory_dir);
eprintln!(" embedder: {:?}", cli.embedder);
match cli.embedder {
EmbedderBackend::Candle => {
eprintln!(
" model: {} ({}d) — in-process Candle, CPU-only",
cli.embedding_model.as_deref().unwrap_or("nomic-embed-text"),
cli.embedding_dims.unwrap_or(768)
);
}
EmbedderBackend::Ollama => {
let url = cli
.embedding_url
.as_deref()
.unwrap_or("http://localhost:11434");
eprintln!(
" embedding: {} @ {} ({}d) — Ollama GPU-accelerated",
cli.embedding_model.as_deref().unwrap_or("nomic-embed-text"),
url,
cli.embedding_dims.unwrap_or(768)
);
let model = cli.embedding_model.as_deref().unwrap_or("nomic-embed-text");
if let Err(e) = reqwest::blocking::get(format!("{}/api/tags", url)) {
eprintln!(" WARNING: Ollama not reachable at {} — {}", url, e);
eprintln!(" Make sure Ollama is running: `ollama serve`");
eprintln!(" And the model is pulled: `ollama pull {}`", model);
eprintln!(" Falling back would require restart with --embedder candle");
anyhow::bail!("Ollama not reachable at {}", url);
} else {
eprintln!(" Ollama health check: OK");
}
}
EmbedderBackend::Mock => {
eprintln!(
" mock embedder ({}d) — for testing only",
cli.embedding_dims.unwrap_or(768)
);
}
}
let bridge_config = bridge::BridgeConfig::from_args(
&cli.memory_dir,
cli.embedder,
cli.embedding_url.as_deref(),
cli.embedding_model.as_deref(),
cli.embedding_dims,
cli.turbo_quant,
cli.turbo_quant_bits,
cli.turbo_quant_projections,
);
let bridge = bridge::MemoryBridge::open(bridge_config)?;
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(4)
.build()?;
let server = server::SemanticMemoryServer::new(bridge.clone(), &cli.tool_profile);
#[cfg(all(feature = "stable", not(feature = "full")))]
if cli.http_port.is_some() || cli.http_only {
anyhow::bail!(
"HTTP transport is unavailable in the compile-time stable build; use stdio MCP or rebuild with --features full"
);
}
#[cfg(not(all(feature = "stable", not(feature = "full"))))]
if let Some(port) = cli.http_port {
let auth_token = resolve_http_auth_token(
cli.http_auth_token.as_deref(),
cli.http_auth_token_file.as_deref(),
)?
.ok_or_else(|| {
anyhow::anyhow!("--http-port requires --http-auth-token or --http-auth-token-file. Refusing to start HTTP server without authorization.")
})?;
http_server::start_http_server(port, &auth_token, bridge, rt.handle().clone());
}
#[cfg(not(all(feature = "stable", not(feature = "full"))))]
if cli.http_only {
eprintln!("HTTP-only mode: stdio MCP disabled, serving HTTP requests.");
rt.block_on(async {
std::future::pending::<()>().await;
});
return Ok(());
}
rt.block_on(async {
let service = server.serve(rmcp::transport::stdio()).await?;
service.waiting().await?;
Ok::<(), anyhow::Error>(())
})?;
Ok(())
}
#[cfg(all(test, not(all(feature = "stable", not(feature = "full")))))]
mod tests {
use super::*;
#[test]
fn token_file_trims_surrounding_whitespace() {
let directory = tempfile::tempdir().expect("temporary directory");
let path = directory.path().join("token");
std::fs::write(&path, " file-token\n").expect("write token");
let token = resolve_http_auth_token(None, Some(&path))
.expect("valid token file")
.expect("resolved token");
assert_eq!(token, "file-token");
}
#[test]
fn explicit_token_precedes_token_file() {
let directory = tempfile::tempdir().expect("temporary directory");
let path = directory.path().join("token");
std::fs::write(&path, "file-token\n").expect("write token");
let token = resolve_http_auth_token(Some("explicit-token"), Some(&path))
.expect("valid explicit token")
.expect("resolved token");
assert_eq!(token, "explicit-token");
}
#[test]
fn exactly_empty_explicit_token_falls_back_to_file() {
let directory = tempfile::tempdir().expect("temporary directory");
let path = directory.path().join("token");
std::fs::write(&path, "file-token\n").expect("write token");
let token = resolve_http_auth_token(Some(""), Some(&path))
.expect("empty explicit token falls back")
.expect("resolved token");
assert_eq!(token, "file-token");
}
#[test]
fn explicit_token_rejects_unicode_internal_whitespace() {
let error = resolve_http_auth_token(Some("first\u{a0}second"), None)
.expect_err("Unicode whitespace must fail")
.to_string();
assert!(error.contains("contains whitespace"));
}
#[test]
fn token_file_rejects_internal_whitespace() {
let directory = tempfile::tempdir().expect("temporary directory");
let path = directory.path().join("token");
std::fs::write(&path, "first\nsecond\n").expect("write token");
let error = resolve_http_auth_token(None, Some(&path))
.expect_err("multiline token must fail")
.to_string();
assert!(error.contains("contains whitespace"));
}
#[test]
fn missing_token_sources_resolve_to_none() {
assert!(resolve_http_auth_token(None, None)
.expect("missing token is not a parse error")
.is_none());
}
}