use std::net::SocketAddr;
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Parser, ValueEnum};
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Transport {
Stdio,
Http,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum NamespaceStrategy {
Global,
Project,
}
#[derive(Parser, Debug)]
#[command(
name = "hms-mcp",
version,
about = "MCP server for Holographic Memory: remember, recall, search, forget, list, stats and export over stdio or streamable HTTP"
)]
pub struct Cli {
#[arg(long, env = "HMS_MCP_TRANSPORT", value_enum, default_value = "stdio")]
pub transport: Transport,
#[arg(long, env = "HMS_MCP_BIND", default_value = "127.0.0.1:8765")]
pub bind: SocketAddr,
#[arg(long, env = "HMS_MCP_PATH", default_value = "/mcp")]
pub path: String,
#[arg(
long = "allow-host",
env = "HMS_MCP_ALLOW_HOSTS",
value_delimiter = ','
)]
pub allow_hosts: Vec<String>,
#[arg(
long = "allow-origin",
env = "HMS_MCP_ALLOW_ORIGINS",
value_delimiter = ','
)]
pub allow_origins: Vec<String>,
#[arg(long, env = "HMS_MCP_ALLOW_REMOTE")]
pub allow_remote: bool,
#[arg(long, env = "HMS_MCP_MAX_REQUEST_BYTES", default_value_t = 1 << 20)]
pub max_request_bytes: usize,
#[arg(long, env = "HMS_MCP_DATA_DIR")]
pub data_dir: Option<PathBuf>,
#[arg(long, env = "HMS_MCP_STORE", conflicts_with_all = ["data_dir", "namespace_arg"])]
pub store: Option<PathBuf>,
#[arg(
long,
env = "HMS_MCP_NAMESPACE_STRATEGY",
value_enum,
default_value = "global"
)]
pub namespace_strategy: NamespaceStrategy,
#[arg(long, env = "HMS_MCP_NAMESPACE", default_value = "default")]
pub namespace: String,
#[arg(long, env = "HMS_MCP_PROJECT")]
pub project: Option<PathBuf>,
#[arg(long, env = "HMS_MCP_NAMESPACE_ARG")]
pub namespace_arg: bool,
#[arg(long, env = "HMS_MCP_MAX_STORES", default_value_t = 16)]
pub max_stores: usize,
#[arg(long, env = "HMS_MCP_DIM", default_value_t = 4096)]
pub dim: u32,
#[arg(long, env = "HMS_MCP_ENCRYPT")]
pub encrypt: bool,
#[arg(
long,
env = "HMS_MCP_PASSPHRASE_ENV",
default_value = "HMS_MCP_PASSPHRASE"
)]
pub passphrase_env: String,
#[arg(long, env = "HMS_MCP_TOOLS", value_delimiter = ',')]
pub tools: Vec<String>,
#[arg(long, env = "HMS_MCP_READ_ONLY")]
pub read_only: bool,
#[arg(long, env = "HMS_MCP_NO_RESOURCES")]
pub no_resources: bool,
#[arg(long, env = "HMS_MCP_NO_PROMPTS")]
pub no_prompts: bool,
#[arg(long, env = "HMS_MCP_MAX_TEXT_BYTES", default_value_t = 32 * 1024)]
pub max_text_bytes: usize,
#[arg(long, env = "HMS_MCP_MAX_METADATA_BYTES", default_value_t = 16 * 1024)]
pub max_metadata_bytes: usize,
#[arg(long, env = "HMS_MCP_MAX_TAGS", default_value_t = 32)]
pub max_tags: usize,
#[arg(long, env = "HMS_MCP_MAX_TOP_K", default_value_t = 50)]
pub max_top_k: usize,
#[arg(long, env = "HMS_MCP_MAX_PAGE", default_value_t = 200)]
pub max_page: usize,
#[arg(long, env = "HMS_MCP_MAX_EXPORT_BYTES", default_value_t = 4 << 20)]
pub max_export_bytes: usize,
#[arg(long, env = "HMS_MCP_MAX_FORGET", default_value_t = 1000)]
pub max_forget: usize,
#[arg(long, env = "HMS_MCP_MAX_CONCURRENT", default_value_t = 4)]
pub max_concurrent: usize,
#[arg(long, env = "HMS_MCP_LOG", default_value = "warn")]
pub log: String,
#[cfg(feature = "local-models")]
#[command(flatten)]
pub models: ModelArgs,
}
#[cfg(feature = "local-models")]
#[derive(clap::Args, Debug)]
pub struct ModelArgs {
#[arg(long, env = "HMS_EMBED_MODEL", requires = "embed_revision")]
pub embed_model: Option<PathBuf>,
#[arg(long, env = "HMS_EMBED_REVISION")]
pub embed_revision: Option<String>,
}
pub const HARD_MAX_TEXT_BYTES: usize = 64 * 1024;
pub fn valid_namespace(name: &str) -> bool {
(1..=64).contains(&name.len())
&& name
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-')
}
impl Cli {
pub fn validate(&self) -> Result<()> {
if !valid_namespace(&self.namespace) {
bail!("--namespace must be 1 to 64 characters of [a-z0-9_-]");
}
if !self.path.starts_with('/') || self.path.len() < 2 || self.path.contains("//") {
bail!("--path must start with / and name a sub-path such as /mcp");
}
if !(256..=1_000_000).contains(&self.dim) {
bail!("--dim must be 256..=1000000");
}
if !(1..=HARD_MAX_TEXT_BYTES).contains(&self.max_text_bytes) {
bail!("--max-text-bytes must be 1..={HARD_MAX_TEXT_BYTES}");
}
for (flag, value, max) in [
("--max-top-k", self.max_top_k, 1000),
("--max-page", self.max_page, 10_000),
("--max-stores", self.max_stores, 1024),
("--max-concurrent", self.max_concurrent, 256),
("--max-tags", self.max_tags, 1024),
("--max-forget", self.max_forget, 100_000),
] {
if !(1..=max).contains(&value) {
bail!("{flag} must be 1..={max}");
}
}
if self.max_metadata_bytes == 0 || self.max_export_bytes < 1024 {
bail!("--max-metadata-bytes must be positive and --max-export-bytes at least 1024");
}
if self.max_request_bytes < 1024 {
bail!("--max-request-bytes must be at least 1024");
}
if self.encrypt {
if !cfg!(feature = "security") {
bail!("--encrypt needs a build with the `security` feature (build with --features mcp,security)");
}
let present = std::env::var(&self.passphrase_env).is_ok_and(|v| !v.is_empty());
if !present {
bail!(
"--encrypt needs the passphrase in the environment variable {}",
self.passphrase_env
);
}
}
if self.transport == Transport::Http {
let loopback = self.bind.ip().is_loopback();
let token = http_token()?.is_some();
if !(loopback || (self.allow_remote && token)) {
bail!(
"binding {} needs --allow-remote and HMS_MCP_TOKEN set",
self.bind
);
}
}
Ok(())
}
pub fn resolved_data_dir(&self) -> Result<PathBuf> {
if let Some(dir) = &self.data_dir {
return Ok(dir.clone());
}
let home = std::env::var_os("HOME").map(PathBuf::from);
let base = if cfg!(target_os = "macos") {
home.map(|h| h.join("Library/Application Support"))
} else if cfg!(windows) {
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
} else {
std::env::var_os("XDG_DATA_HOME")
.map(PathBuf::from)
.or_else(|| home.map(|h| h.join(".local/share")))
};
Ok(base
.context("cannot locate a data directory; set --data-dir or HMS_MCP_DATA_DIR")?
.join("hms-mcp"))
}
}
pub fn http_token() -> Result<Option<String>> {
match std::env::var("HMS_MCP_TOKEN") {
Ok(t) if t.is_empty() => bail!("HMS_MCP_TOKEN is set but empty"),
Ok(t) if t.len() < 16 => bail!("HMS_MCP_TOKEN must be at least 16 characters"),
Ok(t) => Ok(Some(t)),
Err(_) => Ok(None),
}
}