mod messages;
mod rpc;
mod tools;
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use clap::Parser;
const ENV_DB: &str = "PLUGMEM_DB";
const ENV_WORKSPACE: &str = "PLUGMEM_WORKSPACE";
const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(1);
const MCP_SETTING_KEYS: &[(&str, &str)] = &[("server", "workers")];
#[derive(Parser)]
#[command(name = "plugmem-mcp", version, about = messages::ABOUT_CLI)]
struct Args {
#[arg(long)]
db: Option<String>,
#[arg(long)]
workspace: Option<PathBuf>,
#[arg(long = "allow", value_name = "NAME")]
allow: Vec<String>,
#[arg(long)]
no_create: bool,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
read_only: bool,
#[arg(long)]
workers: Option<usize>,
}
fn main() -> ExitCode {
match run(Args::parse()) {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("plugmem-mcp: {message}");
ExitCode::from(2)
}
}
}
fn run(args: Args) -> Result<(), String> {
let Args {
db: cli_db,
workspace,
allow,
no_create,
config,
read_only,
workers,
} = args;
let env_db = std::env::var_os(ENV_DB).map(|s| s.to_string_lossy().into_owned());
let use_config_or_default_db = cli_db.is_none() && env_db.is_none();
let table = plugmem_host::read_config(config.as_deref()).map_err(|e| e.to_string())?;
let workers = workers.unwrap_or_else(|| resolve_workers(table.as_ref()));
let settings = plugmem_host::Settings::from_table(table.as_ref()).map_err(|e| e.to_string())?;
let root = workspace
.or_else(|| std::env::var_os(ENV_WORKSPACE).map(PathBuf::from))
.or_else(|| settings.workspace.dir.clone());
if let Some(root) = root {
return serve_workspace(
settings,
&root,
cli_db.or(env_db),
&allow,
WorkspaceMode {
read_only,
create: !no_create,
},
workers,
);
}
let path = cli_db
.map(PathBuf::from)
.or(env_db.map(PathBuf::from))
.or_else(|| settings.database_path.clone())
.or_else(plugmem_host::default_database_path)
.unwrap_or_else(|| PathBuf::from("plugmem.db"));
if use_config_or_default_db
&& let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
return Err(format!(
"cannot create database directory {}: {e}",
parent.display()
));
}
let shared = if read_only {
let embedder = settings.embedder;
let db = plugmem_host::Database::open_readonly(&path, settings.config)
.map_err(|e| format!("{}: {e}", path.display()))?;
rpc::Shared::Reader(Arc::new(tools::ReaderShared::new(db, embedder)))
} else {
let db = settings
.open(&path)
.map_err(|e| format!("{}: {e}", path.display()))?;
rpc::Shared::Writer(db)
};
rpc::serve(shared, workers);
Ok(())
}
struct WorkspaceMode {
read_only: bool,
create: bool,
}
fn serve_workspace(
settings: plugmem_host::Settings,
root: &std::path::Path,
default: Option<String>,
allow: &[String],
mode: WorkspaceMode,
workers: usize,
) -> Result<(), String> {
if mode.read_only {
return Err(messages::WORKSPACE_READ_ONLY.to_string());
}
let default = default.map(|s| parse_name(&s, "--db")).transpose()?;
let allowed = allow
.iter()
.map(|s| parse_name(s, "--allow"))
.collect::<Result<Vec<_>, _>>()?;
if let Some(name) = &default
&& !allowed.is_empty()
&& !allowed.contains(name)
{
return Err(format!(
"--db {name} is not in the --allow set, so the default memory could never be served"
));
}
let workspace = settings
.open_workspace(root)
.map_err(|e| format!("{}: {e}", root.display()))?;
let shared = Arc::new(tools::WorkspaceShared::new(
workspace,
default,
allowed,
mode.create,
));
let janitor = Arc::clone(&shared);
std::thread::spawn(move || {
loop {
std::thread::sleep(SWEEP_INTERVAL);
janitor.workspace().close_idle(now_ms());
}
});
rpc::serve(rpc::Shared::Workspace(shared), workers);
Ok(())
}
fn parse_name(s: &str, flag: &str) -> Result<plugmem_host::DbName, String> {
plugmem_host::DbName::parse(s).map_err(|e| format!("{flag}: {e}"))
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn resolve_workers(table: Option<&toml::Table>) -> usize {
table
.and_then(|t| t.get(MCP_SETTING_KEYS[0].0))
.and_then(toml::Value::as_table)
.and_then(|s| s.get(MCP_SETTING_KEYS[0].1))
.and_then(toml::Value::as_integer)
.filter(|n| *n > 0)
.map(|n| n as usize)
.unwrap_or_else(default_workers)
}
fn default_workers() -> usize {
std::thread::available_parallelism()
.map(|n| (n.get() / 2).max(1))
.unwrap_or(1)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn workers_from_config_else_default() {
let table: toml::Table = "[server]\nworkers = 3\n".parse().unwrap();
assert_eq!(resolve_workers(Some(&table)), 3);
let empty: toml::Table = "[engine]\ndim = 8\n".parse().unwrap();
assert!(resolve_workers(Some(&empty)) >= 1);
assert!(resolve_workers(None) >= 1);
let zero: toml::Table = "[server]\nworkers = 0\n".parse().unwrap();
assert!(resolve_workers(Some(&zero)) >= 1);
assert!(default_workers() >= 1);
}
#[test]
fn every_mcp_setting_is_documented() {
let docs = plugmem_host::settings_help().docs();
let documented: Vec<_> = docs
.iter()
.filter(|doc| doc.scope == plugmem_host::SettingScope::Mcp)
.map(|doc| (doc.section, doc.key))
.collect();
assert_eq!(documented.as_slice(), MCP_SETTING_KEYS);
}
}