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 = "warm-mcp",
version,
about = "MCP server for WARM: remember, recall, search, forget, list, stats and export over stdio or streamable HTTP"
)]
pub struct Cli {
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_TRANSPORT", "HMS_MCP_TRANSPORT"), value_enum, default_value = "stdio")]
pub transport: Transport,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_BIND", "HMS_MCP_BIND"), default_value = "127.0.0.1:8765")]
pub bind: SocketAddr,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_PATH", "HMS_MCP_PATH"), default_value = "/mcp")]
pub path: String,
#[arg(
long = "allow-host",
env = writerslogic_warm::compatibility::env_name("WARM_MCP_ALLOW_HOSTS", "HMS_MCP_ALLOW_HOSTS"),
value_delimiter = ','
)]
pub allow_hosts: Vec<String>,
#[arg(
long = "allow-origin",
env = writerslogic_warm::compatibility::env_name("WARM_MCP_ALLOW_ORIGINS", "HMS_MCP_ALLOW_ORIGINS"),
value_delimiter = ','
)]
pub allow_origins: Vec<String>,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_ALLOW_REMOTE", "HMS_MCP_ALLOW_REMOTE"))]
pub allow_remote: bool,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_REQUEST_BYTES", "HMS_MCP_MAX_REQUEST_BYTES"), default_value_t = 1 << 20)]
pub max_request_bytes: usize,
#[command(flatten)]
pub http: crate::http_server::HttpLimits,
#[command(flatten)]
pub oauth: crate::oauth::Options,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_DATA_DIR", "HMS_MCP_DATA_DIR"))]
pub data_dir: Option<PathBuf>,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_STORE", "HMS_MCP_STORE"), conflicts_with_all = ["data_dir", "namespace_arg"])]
pub store: Option<PathBuf>,
#[arg(
long,
env = writerslogic_warm::compatibility::env_name("WARM_MCP_NAMESPACE_STRATEGY", "HMS_MCP_NAMESPACE_STRATEGY"),
value_enum,
default_value = "global"
)]
pub namespace_strategy: NamespaceStrategy,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_NAMESPACE", "HMS_MCP_NAMESPACE"), default_value = "default")]
pub namespace: String,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_PROJECT", "HMS_MCP_PROJECT"))]
pub project: Option<PathBuf>,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_NAMESPACE_ARG", "HMS_MCP_NAMESPACE_ARG"))]
pub namespace_arg: bool,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_STORES", "HMS_MCP_MAX_STORES"), default_value_t = 16)]
pub max_stores: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_DIM", "HMS_MCP_DIM"), default_value_t = 4096)]
pub dim: u32,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_ENCRYPT", "HMS_MCP_ENCRYPT"))]
pub encrypt: bool,
#[arg(
long,
env = writerslogic_warm::compatibility::env_name("WARM_MCP_PASSPHRASE_ENV", "HMS_MCP_PASSPHRASE_ENV"),
default_value = "WARM_MCP_PASSPHRASE"
)]
pub passphrase_env: String,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_TOOLS", "HMS_MCP_TOOLS"), value_delimiter = ',')]
pub tools: Vec<String>,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_READ_ONLY", "HMS_MCP_READ_ONLY"))]
pub read_only: bool,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_NO_RESOURCES", "HMS_MCP_NO_RESOURCES"))]
pub no_resources: bool,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_NO_PROMPTS", "HMS_MCP_NO_PROMPTS"))]
pub no_prompts: bool,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_TEXT_BYTES", "HMS_MCP_MAX_TEXT_BYTES"), default_value_t = 32 * 1024)]
pub max_text_bytes: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_METADATA_BYTES", "HMS_MCP_MAX_METADATA_BYTES"), default_value_t = 16 * 1024)]
pub max_metadata_bytes: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_TAGS", "HMS_MCP_MAX_TAGS"), default_value_t = 32)]
pub max_tags: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_TOP_K", "HMS_MCP_MAX_TOP_K"), default_value_t = 50)]
pub max_top_k: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_PAGE", "HMS_MCP_MAX_PAGE"), default_value_t = 200)]
pub max_page: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_EXPORT_BYTES", "HMS_MCP_MAX_EXPORT_BYTES"), default_value_t = 4 << 20)]
pub max_export_bytes: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_FORGET", "HMS_MCP_MAX_FORGET"), default_value_t = 1000)]
pub max_forget: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_CONCURRENT", "HMS_MCP_MAX_CONCURRENT"), default_value_t = 4)]
pub max_concurrent: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_MAX_PENDING", "HMS_MCP_MAX_PENDING"), default_value_t = 32)]
pub max_pending: usize,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_QUEUE_TIMEOUT_MS", "HMS_MCP_QUEUE_TIMEOUT_MS"), default_value_t = 5000)]
pub queue_timeout_ms: u64,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_MCP_LOG", "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 = writerslogic_warm::compatibility::env_name("WARM_EMBED_MODEL", "HMS_EMBED_MODEL"), requires = "embed_revision")]
pub embed_model: Option<PathBuf>,
#[arg(long, env = writerslogic_warm::compatibility::env_name("WARM_EMBED_REVISION", "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<()> {
self.http.validate()?;
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-pending", self.max_pending, 4096),
("--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 !(1..=60_000).contains(&self.queue_timeout_ms) {
bail!("--queue-timeout-ms must be 1..=60000");
}
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 !(1024..=16 << 20).contains(&self.max_request_bytes) {
bail!("--max-request-bytes must be 1024..=16777216");
}
if self.encrypt {
if !cfg!(feature = "security") {
bail!("--encrypt needs a build with the `security` feature (build with --features mcp,security)");
}
let present = writerslogic_warm::compatibility::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();
self.oauth.validate(self, token)?;
if !(loopback || (self.allow_remote && (token || self.oauth.enabled()))) {
bail!(
"binding {} needs --allow-remote and static bearer or OAuth authorization",
self.bind
);
}
} else if self.oauth.enabled() {
bail!("OAuth is only supported on the HTTP transport");
}
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 WARM_MCP_DATA_DIR")?
.join("hms-mcp"))
}
}
pub fn http_token() -> Result<Option<String>> {
match writerslogic_warm::compatibility::var("WARM_MCP_TOKEN") {
Ok(t) if t.is_empty() => bail!("WARM_MCP_TOKEN is set but empty"),
Ok(t) if t.len() < 16 => bail!("WARM_MCP_TOKEN must be at least 16 characters"),
Ok(t) => Ok(Some(t)),
Err(_) => Ok(None),
}
}