mod config;
mod resources;
mod server;
mod tools;
use clap::{Parser, Subcommand};
use tracing_appender::non_blocking;
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use config::ServerConfig;
fn home_dir() -> Option<String> {
std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.ok()
}
#[derive(Parser)]
#[command(name = "mentedb-mcp", about = "MCP server for MenteDB")]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(long, default_value = "~/.mentedb")]
data_dir: String,
#[arg(long, default_value = "128")]
embedding_dim: usize,
#[arg(long, default_value = "mock", env = "MENTEDB_LLM_PROVIDER")]
llm_provider: String,
#[arg(long, env = "MENTEDB_LLM_API_KEY")]
llm_api_key: Option<String>,
#[arg(long, env = "MENTEDB_LLM_MODEL")]
llm_model: Option<String>,
#[arg(long)]
full_tools: bool,
}
#[derive(Subcommand)]
enum Commands {
Setup {
#[arg(value_enum, default_value = "copilot")]
client: SetupClient,
},
Update {
#[arg(value_enum, default_value = "copilot")]
client: SetupClient,
},
Login,
Logout,
Status,
}
#[derive(Clone, clap::ValueEnum)]
enum SetupClient {
Copilot,
Claude,
Cursor,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Some(Commands::Setup { client }) => return run_setup(client, false),
Some(Commands::Update { client }) => return run_setup(client, true),
Some(Commands::Login) => return run_login().await,
Some(Commands::Logout) => return run_logout(),
Some(Commands::Status) => return run_status().await,
None => {}
}
let data_dir = ServerConfig::resolve_data_dir(&cli.data_dir);
std::fs::create_dir_all(&data_dir).ok();
let log_path = data_dir.join("mentedb-mcp.log");
let file_appender = tracing_appender::rolling::daily(&data_dir, "mentedb-mcp.log");
let (file_writer, _guard) = non_blocking(file_appender);
let (stderr_writer, _stderr_guard) = non_blocking(std::io::stderr());
tracing_subscriber::registry()
.with(EnvFilter::from_default_env().add_directive(tracing::Level::INFO.into()))
.with(
fmt::layer()
.with_writer(file_writer)
.with_ansi(false)
.with_target(true)
.with_thread_ids(true),
)
.with(
fmt::layer()
.with_writer(stderr_writer)
.with_ansi(false)
.with_target(false),
)
.init();
tracing::info!(
version = env!("CARGO_PKG_VERSION"),
data_dir = %data_dir.display(),
log_file = %log_path.display(),
embedding_dim = cli.embedding_dim,
llm_provider = %cli.llm_provider,
"mentedb-mcp starting"
);
auto_update_instructions();
validate_cloud_token_startup().await;
let config = ServerConfig::new(
data_dir,
cli.embedding_dim,
cli.llm_provider,
cli.llm_api_key,
cli.llm_model,
cli.full_tools,
);
server::run(config).await
}
fn auto_update_instructions() {
let home = match home_dir() {
Some(h) => h,
None => return,
};
let instruction_paths = [
format!("{home}/.copilot/copilot-instructions.md"),
format!("{home}/.cursor/rules/mentedb.md"),
];
for path_str in &instruction_paths {
let path = std::path::Path::new(path_str);
if path.exists() {
match append_instructions(path, false, false) {
Ok(()) => {}
Err(e) => {
tracing::warn!(path = %path.display(), error = %e, "auto-update instructions failed");
}
}
}
}
}
const TOOL_NAMES: &[&str] = &[
"process_turn",
"store_memory",
"search_memories",
"forget_memory",
];
const AGENT_INSTRUCTIONS: &str = r#"# Memory
You have persistent memory via MenteDB. You have 4 tools — use them.
## process_turn — CALL EVERY TURN
Call `process_turn` on EVERY turn. Pass `user_message` and `assistant_response`.
- It returns relevant context from past conversations, stores the current turn, and detects contradictions.
- Without this call, nothing is remembered.
- Increment `turn_id` each turn (start at 0).
- `assistant_response` can be brief or empty if you have not yet drafted a response.
## USE the returned context
When `process_turn` returns:
- **context**: Summaries with IDs from past conversations. Reference them in your response. Call `search_memories(id)` for full content.
- **pain_warnings**: If non-empty, WARN the user — a similar situation caused problems before.
- **contradictions**: If > 0, flag the inconsistency.
## store_memory — Save important facts
When you notice important information, call `store_memory`:
- **Preferences**: "User prefers Rust" → type: semantic, tags: [preference]
- **Decisions**: "Using PostgreSQL" → type: semantic, tags: [decision]
- **Corrections**: "Deadline is April, not March" → type: correction
- **Procedures**: "Deploy with: cargo build --release" → type: procedural
- **Mistakes**: "Never retag releases" → type: anti_pattern
Don't store chitchat. Store facts, preferences, decisions, corrections.
## search_memories — Look things up
Pass a text query to search by similarity, or a memory UUID to get full content.
Use proactively — if the user mentions a project, search for what you know about it.
## forget_memory — Delete memories
When the user says "forget" or "don't remember that", delete the memory by ID.
"#;
async fn run_login() -> anyhow::Result<()> {
use std::net::TcpListener;
use tokio::sync::oneshot;
println!("\nAuthenticating with MenteDB Cloud...\n");
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
drop(listener);
let (tx, rx) = oneshot::channel::<String>();
let tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(tx)));
let cors_headers = [
(axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
(
axum::http::header::ACCESS_CONTROL_ALLOW_HEADERS,
"content-type",
),
(
axum::http::header::ACCESS_CONTROL_ALLOW_METHODS,
"POST, OPTIONS",
),
];
let cors_for_options = cors_headers.clone();
let cors_for_post = cors_headers;
let tx_clone = tx.clone();
let server = axum::Router::new()
.route(
"/callback",
axum::routing::post(move |body: axum::Json<serde_json::Value>| {
let tx = tx_clone.clone();
async move {
let api_key = body
.get("api_key")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if let Some(sender) = tx.lock().await.take() {
let _ = sender.send(api_key);
}
(
axum::http::StatusCode::OK,
cors_for_post.map(|(k, v)| (k, axum::http::HeaderValue::from_static(v))),
axum::Json(serde_json::json!({"ok": true})),
)
}
}),
)
.route(
"/callback",
axum::routing::options(|| async move {
(
axum::http::StatusCode::OK,
cors_for_options.map(|(k, v)| (k, axum::http::HeaderValue::from_static(v))),
"",
)
}),
);
let listener = tokio::net::TcpListener::bind(format!("127.0.0.1:{port}")).await?;
let server_handle = tokio::spawn(async move {
axum::serve(listener, server).await.ok();
});
let cloud_url = std::env::var("MENTEDB_CLOUD_URL")
.unwrap_or_else(|_| "https://app.mentedb.com".to_string());
let url = format!("{cloud_url}/auth/device?callback_port={port}");
println!(" Opening browser: {url}");
println!(" Waiting for authorization...\n");
if open::that(&url).is_err() {
println!(" Could not open browser automatically.");
println!(" Please visit: {url}\n");
}
let token = tokio::time::timeout(std::time::Duration::from_secs(300), rx)
.await
.map_err(|_| anyhow::anyhow!("Login timed out after 5 minutes"))?
.map_err(|_| anyhow::anyhow!("Login cancelled"))?;
if token.is_empty() {
anyhow::bail!("Received empty token");
}
let home = home_dir().unwrap_or_else(|| "~".to_string());
let mentedb_dir = std::path::PathBuf::from(&home).join(".mentedb");
std::fs::create_dir_all(&mentedb_dir)?;
let cloud_config = serde_json::json!({
"api_url": std::env::var("MENTEDB_API_URL")
.unwrap_or_else(|_| "https://api.mentedb.com".to_string()),
"token": token,
});
let config_path = mentedb_dir.join("cloud.json");
std::fs::write(&config_path, serde_json::to_string_pretty(&cloud_config)?)?;
server_handle.abort();
println!(" Authenticated successfully!");
println!(" Credentials saved to: {}", config_path.display());
println!("\n Your MCP server will now sync with MenteDB Cloud.");
Ok(())
}
fn run_logout() -> anyhow::Result<()> {
let home = home_dir().unwrap_or_else(|| "~".to_string());
let mentedb_dir = std::path::PathBuf::from(&home).join(".mentedb");
let config_path = mentedb_dir.join("cloud.json");
if config_path.exists() {
std::fs::remove_file(&config_path)?;
println!(" Logged out. Cloud credentials removed.");
} else {
println!(" Not logged in (no credentials found).");
}
Ok(())
}
async fn run_status() -> anyhow::Result<()> {
let home = home_dir().unwrap_or_else(|| "~".to_string());
let mentedb_dir = std::path::PathBuf::from(&home).join(".mentedb");
let config_path = mentedb_dir.join("cloud.json");
if !config_path.exists() {
println!(" Status: Not logged in");
println!(" Run `mentedb-mcp login` to authenticate.");
return Ok(());
}
let config_str = std::fs::read_to_string(&config_path)?;
let config: serde_json::Value = serde_json::from_str(&config_str)?;
let api_url = config["api_url"]
.as_str()
.unwrap_or("https://api.mentedb.com");
let token = config["token"].as_str().unwrap_or("");
if token.is_empty() {
println!(" Status: Invalid credentials (empty token)");
println!(" Run `mentedb-mcp login` to re-authenticate.");
return Ok(());
}
println!(" Checking cloud connection...");
let client = reqwest::Client::new();
let res = client
.get(format!("{api_url}/api/sessions"))
.header("Authorization", format!("Bearer {token}"))
.timeout(std::time::Duration::from_secs(10))
.send()
.await;
match res {
Ok(resp) => match resp.status().as_u16() {
200 => {
println!(" Status: Connected");
println!(" Cloud URL: {api_url}");
let masked = format!("mdb_{}...", &token[4..12]);
println!(" Token: {masked}");
}
401 | 403 => {
println!(" Status: Session revoked");
println!();
println!(" Your session has been revoked (possibly from the web dashboard).");
println!(" Run `mentedb-mcp login` to create a new session.");
std::fs::remove_file(&config_path).ok();
}
code => {
println!(" Status: Error (HTTP {code})");
println!(" The cloud service may be temporarily unavailable.");
}
},
Err(e) => {
if e.is_timeout() {
println!(" Status: Timeout");
println!(" Could not reach {api_url} within 10 seconds.");
} else {
println!(" Status: Connection failed");
println!(" Error: {e}");
}
}
}
Ok(())
}
async fn validate_cloud_token_startup() {
let home = match home_dir() {
Some(h) => h,
None => return,
};
let config_path = std::path::PathBuf::from(&home)
.join(".mentedb")
.join("cloud.json");
if !config_path.exists() {
return;
}
let config_str = match std::fs::read_to_string(&config_path) {
Ok(s) => s,
Err(_) => return,
};
let config: serde_json::Value = match serde_json::from_str(&config_str) {
Ok(v) => v,
Err(_) => return,
};
let api_url = config["api_url"]
.as_str()
.unwrap_or("https://api.mentedb.com");
let token = match config["token"].as_str() {
Some(t) if !t.is_empty() => t,
_ => return,
};
let client = reqwest::Client::new();
let res = client
.get(format!("{api_url}/api/sessions"))
.header("Authorization", format!("Bearer {token}"))
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
match res {
Ok(resp) if resp.status().as_u16() == 401 || resp.status().as_u16() == 403 => {
tracing::error!(
"Cloud session has been revoked. Run `mentedb-mcp login` to re-authenticate."
);
eprintln!(
"[mentedb-mcp] ERROR: Your cloud session has been revoked. \
Run `mentedb-mcp login` to create a new session."
);
std::fs::remove_file(&config_path).ok();
}
Ok(resp) if resp.status().is_success() => {
tracing::info!("Cloud connection verified");
}
_ => {
tracing::debug!("Could not validate cloud token at startup (network issue)");
}
}
}
fn run_setup(client: SetupClient, force: bool) -> anyhow::Result<()> {
let home = home_dir().unwrap_or_else(|| "~".to_string());
let binary = which_mentedb_mcp();
match client {
SetupClient::Copilot => setup_copilot(&home, &binary, force),
SetupClient::Claude => setup_claude(&home, &binary, force),
SetupClient::Cursor => setup_cursor(&home, &binary, force),
}
}
fn which_mentedb_mcp() -> String {
let is_npx = std::env::var("npm_execpath").is_ok()
|| std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
.is_some_and(|p| p.contains(".npm/_npx") || p.contains("npx"));
if is_npx {
return "npx".to_string();
}
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| "mentedb-mcp".to_string())
}
fn write_if_missing(path: &std::path::Path, content: &str, label: &str) -> anyhow::Result<bool> {
if path.exists() {
eprintln!(" [skip] {label} already exists: {}", path.display());
Ok(false)
} else {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, content)?;
eprintln!(" [created] {label}: {}", path.display());
Ok(true)
}
}
fn merge_mcp_config(path: &std::path::Path, binary: &str, force: bool) -> anyhow::Result<()> {
let allow_list: Vec<&str> = TOOL_NAMES.to_vec();
let (command, base_args) = if binary == "npx" {
("npx", vec!["-y", "mentedb-mcp@latest"])
} else {
(binary, vec![])
};
let mut mentedb_entry = serde_json::json!({
"command": command,
"args": base_args,
"alwaysAllow": allow_list,
});
let mut env_vars = serde_json::Map::new();
if let Ok(provider) = std::env::var("MENTEDB_LLM_PROVIDER") {
env_vars.insert(
"MENTEDB_LLM_PROVIDER".to_string(),
serde_json::Value::String(provider),
);
}
if let Ok(key) = std::env::var("MENTEDB_LLM_API_KEY") {
env_vars.insert(
"MENTEDB_LLM_API_KEY".to_string(),
serde_json::Value::String(key),
);
} else if let Ok(key) = std::env::var("OPENAI_API_KEY") {
env_vars.insert("OPENAI_API_KEY".to_string(), serde_json::Value::String(key));
}
if let Ok(model) = std::env::var("MENTEDB_LLM_MODEL") {
env_vars.insert(
"MENTEDB_LLM_MODEL".to_string(),
serde_json::Value::String(model),
);
}
if !env_vars.is_empty() {
mentedb_entry
.as_object_mut()
.unwrap()
.insert("env".to_string(), serde_json::Value::Object(env_vars));
}
let mut config: serde_json::Value = if path.exists() {
let content = std::fs::read_to_string(path)?;
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}))
} else {
serde_json::json!({})
};
let servers = config
.as_object_mut()
.unwrap()
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let existing = servers.get("mentedb").cloned();
if existing.is_some() && !force {
eprintln!(" [skip] mentedb already in MCP config: {}", path.display());
} else {
if let Some(old) = &existing
&& let Some(old_obj) = old.as_object()
{
let new_obj = mentedb_entry.as_object_mut().unwrap();
if let Some(old_args) = old_obj.get("args").and_then(|a| a.as_array()) {
let user_args: Vec<&serde_json::Value> = old_args
.iter()
.filter(|a| {
let s = a.as_str().unwrap_or_default();
s != "-y" && !s.starts_with("mentedb-mcp")
})
.collect();
if !user_args.is_empty() {
let new_args = new_obj
.get_mut("args")
.and_then(|a| a.as_array_mut())
.unwrap();
for arg in user_args {
new_args.push(arg.clone());
}
}
}
if let Some(old_env) = old_obj.get("env").and_then(|e| e.as_object()) {
let new_env = new_obj
.entry("env")
.or_insert_with(|| serde_json::json!({}));
if let Some(new_env_obj) = new_env.as_object_mut() {
for (k, v) in old_env {
new_env_obj.entry(k.clone()).or_insert(v.clone());
}
}
}
}
servers
.as_object_mut()
.unwrap()
.insert("mentedb".to_string(), mentedb_entry);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, serde_json::to_string_pretty(&config)?)?;
let verb = if existing.is_some() {
"updated"
} else {
"created"
};
eprintln!(" [{verb}] MCP config: {}", path.display());
}
Ok(())
}
fn append_instructions(
path: &std::path::Path,
interactive: bool,
force: bool,
) -> anyhow::Result<()> {
let version_marker = format!(
"<!-- mentedb-instructions-v{} -->",
env!("CARGO_PKG_VERSION")
);
if path.exists() {
let content = std::fs::read_to_string(path)?;
if content.contains(&version_marker) && !force {
eprintln!(
" [skip] instructions already up-to-date: {}",
path.display()
);
return Ok(());
}
let old_version = content
.lines()
.find(|l| l.contains("<!-- mentedb-instructions-v"))
.and_then(|l| {
l.trim()
.strip_prefix("<!-- mentedb-instructions-v")
.and_then(|s| s.strip_suffix(" -->"))
})
.unwrap_or("unknown");
let has_mentedb_block =
content.contains("# Memory\n\nYou have persistent memory via MenteDB");
let user_customized = if has_mentedb_block {
let start = content
.find("# Memory\n\nYou have persistent memory via MenteDB")
.unwrap();
let rest = &content[start..];
let end = rest
.find("\n# ")
.map(|i| start + i)
.unwrap_or(content.len());
let old_block = &content[start..end];
let agent_lines: std::collections::HashSet<&str> =
AGENT_INSTRUCTIONS.lines().map(|l| l.trim()).collect();
old_block
.lines()
.any(|l| !l.trim().is_empty() && !agent_lines.contains(l.trim()))
} else {
false
};
if interactive {
eprintln!(
"\n Agent instructions update: v{} → v{}",
old_version,
env!("CARGO_PKG_VERSION")
);
eprintln!(" File: {}", path.display());
eprintln!("\n ┌─── Instructions to be written ───────────────────────────");
for line in AGENT_INSTRUCTIONS.lines() {
eprintln!(" │ {line}");
}
eprintln!(" └─────────────────────────────────────────────────────────");
if user_customized {
eprintln!("\n ⚠️ Your file has custom edits inside the MenteDB block.");
eprintln!(
" These will be replaced. Your content OUTSIDE the block is preserved."
);
eprintln!(" A backup will be saved to: {}.bak", path.display());
}
eprint!("\n Apply these instructions? [Y/n] ");
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input == "n" || input == "no" {
eprintln!(" [skip] user declined update");
return Ok(());
}
if user_customized {
let backup_path = path.with_extension("md.bak");
std::fs::copy(path, &backup_path)?;
eprintln!(" [backup] saved to: {}", backup_path.display());
}
}
let cleaned = if has_mentedb_block {
let start = content
.find("# Memory\n\nYou have persistent memory via MenteDB")
.unwrap();
let rest = &content[start..];
let end = rest
.find("\n# ")
.map(|i| start + i)
.unwrap_or(content.len());
let mut prefix_end = start;
if let Some(marker_start) = content[..start].rfind("<!-- mentedb-instructions-v") {
prefix_end = marker_start;
}
let prefix = content[..prefix_end].trim_end().to_string();
let suffix = content[end..].to_string();
format!("{prefix}\n{suffix}")
} else {
content
};
let updated = format!(
"{}\n\n{version_marker}\n{AGENT_INSTRUCTIONS}",
cleaned.trim_end()
);
std::fs::write(path, updated)?;
eprintln!(
" [updated] refreshed MenteDB instructions: {}",
path.display()
);
} else {
let content = format!("{version_marker}\n{AGENT_INSTRUCTIONS}");
write_if_missing(path, &content, "agent instructions")?;
}
Ok(())
}
fn setup_copilot(home: &str, binary: &str, force: bool) -> anyhow::Result<()> {
println!("\nSetting up MenteDB for GitHub Copilot CLI...\n");
let copilot_dir = std::path::PathBuf::from(home).join(".copilot");
merge_mcp_config(&copilot_dir.join("mcp-config.json"), binary, force)?;
append_instructions(&copilot_dir.join("copilot-instructions.md"), true, force)?;
println!("\nDone! Restart Copilot CLI to activate MenteDB memory.");
println!("\nTo sync memories across devices, run: mentedb-mcp login");
Ok(())
}
fn setup_claude(home: &str, binary: &str, force: bool) -> anyhow::Result<()> {
println!("\nSetting up MenteDB for Claude Desktop...\n");
let config_dir = if cfg!(target_os = "macos") {
std::path::PathBuf::from(home).join("Library/Application Support/Claude")
} else if cfg!(target_os = "windows") {
std::env::var("APPDATA")
.map(|d| std::path::PathBuf::from(d).join("Claude"))
.unwrap_or_else(|_| std::path::PathBuf::from(home).join("AppData/Roaming/Claude"))
} else {
std::path::PathBuf::from(home).join(".config/Claude")
};
merge_mcp_config(
&config_dir.join("claude_desktop_config.json"),
binary,
force,
)?;
println!("\nDone! Restart Claude Desktop to activate MenteDB memory.");
println!("Note: Claude Desktop reads server instructions automatically from the MCP server.");
println!("\nTo sync memories across devices, run: mentedb-mcp login");
Ok(())
}
fn setup_cursor(home: &str, binary: &str, force: bool) -> anyhow::Result<()> {
println!("\nSetting up MenteDB for Cursor...\n");
let cursor_dir = std::path::PathBuf::from(home).join(".cursor");
merge_mcp_config(&cursor_dir.join("mcp.json"), binary, force)?;
append_instructions(&cursor_dir.join("rules/mentedb.md"), true, force)?;
println!("\nDone! Restart Cursor to activate MenteDB memory.");
println!("\nTo sync memories across devices, run: mentedb-mcp login");
Ok(())
}