use anyhow::Result;
use clap::{Parser, Subcommand};
mod cache;
mod chunker;
mod config;
mod embedder;
mod filter;
mod proxy;
mod similarity;
mod stats;
mod token;
const DEFAULT_DB: &str = "~/.mcpkill.db";
#[derive(Parser)]
#[command(
name = "mcpkill",
about = "Universal MCP proxy — semantic cache + chunking to kill token waste",
version
)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(last = true)]
target: Vec<String>,
#[arg(long)]
max_chunks: Option<usize>,
#[arg(long)]
threshold: Option<f32>,
#[arg(long)]
ttl_days: Option<u64>,
#[arg(long)]
max_db_mb: Option<u64>,
#[arg(long)]
dry_run: bool,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
cache_db: Option<String>,
#[arg(long, default_value = "0")]
stats_interval: u64,
}
#[derive(Subcommand)]
enum Commands {
Stats {
#[arg(long)]
json: bool,
#[arg(long)]
cache_db: Option<String>,
},
PrintConfig,
Clear {
#[arg(long)]
all: bool,
#[arg(long)]
older_than: Option<u64>,
#[arg(long)]
expired: bool,
#[arg(long)]
cache_db: Option<String>,
},
Install {
name: String,
#[arg(short, long, default_value = "user")]
scope: String,
#[arg(last = true)]
target: Vec<String>,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
let file_cfg = config::FileConfig::load()?;
match cli.command {
Some(Commands::PrintConfig) => {
let db = resolve_db(cli.cache_db, &file_cfg);
eprintln!("Effective configuration:");
eprintln!(
" max_chunks = {}",
cli.max_chunks.or(file_cfg.max_chunks).unwrap_or(4)
);
eprintln!(
" threshold = {}",
cli.threshold.or(file_cfg.threshold).unwrap_or(0.85)
);
eprintln!(
" ttl_days = {}",
cli.ttl_days.or(file_cfg.ttl_days).unwrap_or(7)
);
eprintln!(
" max_db_mb = {}",
cli.max_db_mb.or(file_cfg.max_db_mb).unwrap_or(100)
);
eprintln!(" cache_db = {db}");
eprintln!(" dry_run = {}", cli.dry_run);
eprintln!(" verbose = {}", cli.verbose);
eprintln!(" stats_interval = {}s", cli.stats_interval);
Ok(())
}
Some(Commands::Stats { json, cache_db }) => {
let db = resolve_db(cache_db, &file_cfg);
if json {
stats::print_stats_json(&db)
} else {
stats::print_stats(&db)
}
}
Some(Commands::Clear {
all,
older_than,
expired,
cache_db,
}) => {
if !all && older_than.is_none() && !expired {
eprintln!(
"Error: specify at least one of --all, --older-than <DAYS>, or --expired"
);
eprintln!(" mcpkill clear --expired remove TTL-expired entries");
eprintln!(" mcpkill clear --older-than 14 remove entries unused for 14 days");
eprintln!(" mcpkill clear --all wipe the entire cache");
std::process::exit(1);
}
let db = resolve_db(cache_db, &file_cfg);
let cache = cache::Cache::new(&db)?;
let removed = if all {
cache.clear_all()?
} else if let Some(days) = older_than {
cache.clear_older_than(days)?
} else {
cache.evict_expired(7)?
};
eprintln!("[mcpkill] removed {removed} cache entries");
Ok(())
}
Some(Commands::Install { name, scope, target }) => {
if target.is_empty() {
eprintln!("Usage: mcpkill install <NAME> -- <CMD> [ARGS...]");
eprintln!("Example: mcpkill install context7 -- npx -y @upstash/context7-mcp");
std::process::exit(1);
}
run_install(&name, &scope, &target)?;
Ok(())
}
None => {
if cli.target.is_empty() {
eprintln!("Usage: mcpkill [OPTIONS] -- <CMD> [ARGS...]");
eprintln!(" mcpkill install <NAME> -- <CMD> [ARGS...]");
eprintln!(" mcpkill stats [--json]");
eprintln!(" mcpkill clear [--all | --older-than <DAYS> | --expired]");
eprintln!();
eprintln!("Config file: ~/.mcpkill.toml");
std::process::exit(1);
}
proxy::run(proxy::Config {
target: cli.target,
max_chunks: cli.max_chunks.or(file_cfg.max_chunks).unwrap_or(4),
threshold: cli.threshold.or(file_cfg.threshold).unwrap_or(0.85),
ttl_days: cli.ttl_days.or(file_cfg.ttl_days).unwrap_or(7),
max_db_mb: cli.max_db_mb.or(file_cfg.max_db_mb).unwrap_or(100),
dry_run: cli.dry_run,
verbose: cli.verbose,
stats_interval: cli.stats_interval,
cache_db: resolve_db(cli.cache_db, &file_cfg),
})
.await
}
}
}
fn resolve_db(cli: Option<String>, cfg: &config::FileConfig) -> String {
let raw = cli
.or_else(|| cfg.cache_db.clone())
.unwrap_or_else(|| DEFAULT_DB.to_string());
expand_tilde(&raw)
}
pub fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
let home = std::env::var("HOME").unwrap_or_default();
format!("{home}/{rest}")
} else {
path.to_string()
}
}
fn run_install(name: &str, scope: &str, target: &[String]) -> Result<()> {
let cache_dir = expand_tilde("~/.fastembed_cache");
std::env::set_var("FASTEMBED_CACHE_DIR", &cache_dir);
eprintln!("[mcpkill] Step 1/2 — warming up embedding model …");
embedder::Embedder::new()?;
eprintln!("[mcpkill] ✓ Model ready ({cache_dir})");
let mcpkill_bin = std::env::current_exe()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "mcpkill".to_string());
let mut args: Vec<String> = vec![
"mcp".into(),
"add".into(),
"--scope".into(),
scope.into(),
"-e".into(),
format!("FASTEMBED_CACHE_DIR={cache_dir}"),
"--".into(), name.into(),
mcpkill_bin,
"--".into(),
];
args.extend_from_slice(target);
eprintln!("[mcpkill] Step 2/2 — registering '{name}' in Claude Code (scope: {scope}) …");
let _ = std::process::Command::new("claude")
.args(["mcp", "remove", "--scope", scope, name])
.output();
let status = std::process::Command::new("claude").args(&args).status()?;
if status.success() {
eprintln!("[mcpkill] ✓ Done! Restart Claude Code (or open a new chat) to activate.");
eprintln!(" Verify with: claude mcp list");
} else {
anyhow::bail!("`claude mcp add` failed — is Claude Code installed and in PATH?");
}
Ok(())
}