mcpkill 0.1.0

Universal MCP proxy — semantic cache + chunking to kill token waste
Documentation
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";

// ── CLI ───────────────────────────────────────────────────────────────────────

#[derive(Parser)]
#[command(
    name = "mcpkill",
    about = "Universal MCP proxy — semantic cache + chunking to kill token waste",
    version
)]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,

    // ── Proxy args (all Option<T> so we can detect "not set" vs "default") ──
    /// Target MCP server command (everything after --)
    #[arg(last = true)]
    target: Vec<String>,

    /// Max chunks returned per response [config: max_chunks, default: 4]
    #[arg(long)]
    max_chunks: Option<usize>,

    /// Cosine similarity threshold for cache hits [config: threshold, default: 0.85]
    #[arg(long)]
    threshold: Option<f32>,

    /// Cache TTL in days [config: ttl_days, default: 7]
    #[arg(long)]
    ttl_days: Option<u64>,

    /// Maximum cache DB size in MB before LRU eviction [config: max_db_mb, default: 100]
    #[arg(long)]
    max_db_mb: Option<u64>,

    /// Log what would be filtered but return the original response unchanged
    #[arg(long)]
    dry_run: bool,

    /// Verbose logging to stderr
    #[arg(short, long)]
    verbose: bool,

    /// Cache database path [config: cache_db]
    #[arg(long)]
    cache_db: Option<String>,

    /// Print cache hit/miss stats every N seconds (0 = disabled)
    #[arg(long, default_value = "0")]
    stats_interval: u64,
}

#[derive(Subcommand)]
enum Commands {
    /// Show token savings statistics
    Stats {
        /// Output JSON instead of the formatted table
        #[arg(long)]
        json: bool,

        #[arg(long)]
        cache_db: Option<String>,
    },

    /// Print the effective configuration (file + CLI merged) and exit
    PrintConfig,

    /// Clear cache entries (requires at least one flag)
    Clear {
        /// Remove ALL entries
        #[arg(long)]
        all: bool,

        /// Remove entries not used in the last N days
        #[arg(long)]
        older_than: Option<u64>,

        /// Remove entries whose TTL has expired (default TTL: 7 days)
        #[arg(long)]
        expired: bool,

        #[arg(long)]
        cache_db: Option<String>,
    },

    /// Register mcpkill as a proxy for an MCP server in Claude Code.
    ///
    /// Downloads the embedding model if needed, then runs:
    ///   claude mcp add --scope <SCOPE> -e FASTEMBED_CACHE_DIR=~/.fastembed_cache
    ///     <NAME> -- <mcpkill-path> -- <CMD> [ARGS...]
    ///
    /// Example:
    ///   mcpkill install context7 -- npx -y @upstash/context7-mcp
    Install {
        /// Name to register the server under in Claude Code (e.g., context7)
        name: String,

        /// Claude Code config scope: user (global) | local (project) | project
        #[arg(short, long, default_value = "user")]
        scope: String,

        /// Target MCP server command (everything after --)
        #[arg(last = true)]
        target: Vec<String>,
    },
}

// ── Entry point ───────────────────────────────────────────────────────────────

#[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
        }
    }
}

// ── Helpers ───────────────────────────────────────────────────────────────────

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()
    }
}

// ── Install ───────────────────────────────────────────────────────────────────

fn run_install(name: &str, scope: &str, target: &[String]) -> Result<()> {
    let cache_dir = expand_tilde("~/.fastembed_cache");

    // Step 1: pre-warm the embedding model into the stable cache directory.
    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})");

    // Step 2: register via `claude mcp add`.
    let mcpkill_bin = std::env::current_exe()
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|_| "mcpkill".to_string());

    // Build: claude mcp add --scope <scope> -e KEY=val -- <name> <bin> -- <target...>
    // Note: -e is variadic — `--` after it terminates option parsing so <name> isn't consumed.
    let mut args: Vec<String> = vec![
        "mcp".into(),
        "add".into(),
        "--scope".into(),
        scope.into(),
        "-e".into(),
        format!("FASTEMBED_CACHE_DIR={cache_dir}"),
        "--".into(), // terminates variadic -e parsing; name/bin come after
        name.into(),
        mcpkill_bin,
        "--".into(),
    ];
    args.extend_from_slice(target);

    eprintln!("[mcpkill] Step 2/2 — registering '{name}' in Claude Code (scope: {scope}) …");

    // Remove any existing entry first (ignore errors — it might not exist yet).
    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(())
}