use std::net::SocketAddr;
use std::sync::Arc;
use clap::{ArgAction, Args, Parser, Subcommand};
use emperor_folder::{
Config, Folder, RootSpec, ServeOptions, default_allowed_hosts, doctor, serve_http, serve_stdio,
};
use emperor_mcp::cli::parse_switch;
#[derive(Parser)]
#[command(
name = "emperor-folder",
version,
about = "MCP server confined to one folder"
)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Serve(Opts),
Doctor(Opts),
}
#[derive(Args)]
struct Opts {
#[arg(long = "root", value_name = "[NAME=]DIR", required = true)]
roots: Vec<RootSpec>,
#[arg(long, default_value = "127.0.0.1:8031")]
bind: SocketAddr,
#[arg(
long,
env = "FOLDER_WRITABLE",
action = ArgAction::SetTrue,
value_parser = parse_switch,
)]
writable: bool,
#[arg(long)]
stdio: bool,
#[arg(long)]
hidden: bool,
#[arg(long)]
no_ignore: bool,
#[arg(long, default_value_t = emperor_folder::folder::DEFAULT_MAX_FILE_BYTES)]
max_file_bytes: u64,
#[arg(long, default_value_t = emperor_folder::folder::DEFAULT_MAX_RESULTS)]
max_results: usize,
#[arg(long, default_value_t = emperor_folder::folder::DEFAULT_MAX_CHARS)]
max_chars: usize,
#[arg(long = "allowed-host", value_name = "HOST")]
allowed_hosts: Vec<String>,
#[arg(long = "allowed-origin", value_name = "ORIGIN")]
allowed_origins: Vec<String>,
}
impl Opts {
fn config(&self) -> Config {
let mut config = Config::with_roots(self.roots.clone()).max_chars(self.max_chars);
config.writable = self.writable;
config.hidden = self.hidden;
config.no_ignore = self.no_ignore;
config.max_file_bytes = self.max_file_bytes;
config.max_results = self.max_results.max(1);
config
}
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Doctor(opts) => {
let report = doctor::report(&opts.config(), (!opts.stdio).then_some(opts.bind));
report.print();
std::process::exit(report.exit_code());
}
Command::Serve(opts) => {
init_tracing();
let folder = Arc::new(Folder::open(opts.config())?);
for (name, path) in folder.roots() {
tracing::info!(root = %path.display(), name, "root folder");
}
if opts.writable {
tracing::info!(
writable = true,
"emperor-folder starting: writing on, write, edit and move offered"
);
} else {
tracing::info!(
writable = false,
"emperor-folder starting: read-only, write, edit and move not offered"
);
}
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async move {
if opts.stdio {
serve_stdio(folder).await
} else {
let hosts = if opts.allowed_hosts.is_empty() {
default_allowed_hosts(opts.bind)
} else {
opts.allowed_hosts.clone()
};
let options = ServeOptions::new(opts.bind)
.with_allowed_hosts(hosts)
.with_allowed_origins(opts.allowed_origins.clone());
serve_http(folder, options).await
}
})
}
}
}
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt};
let _ = fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
.with_writer(std::io::stderr)
.try_init();
}
#[cfg(test)]
mod tests {
use super::*;
fn writable(args: &[&str], env: Option<&str>) -> Result<bool, clap::Error> {
unsafe {
match env {
Some(v) => std::env::set_var("FOLDER_WRITABLE", v),
None => std::env::remove_var("FOLDER_WRITABLE"),
}
}
let mut argv = vec!["emperor-folder", "serve", "--root", "/data"];
argv.extend_from_slice(args);
Cli::try_parse_from(argv).map(|cli| match cli.command {
Command::Serve(opts) | Command::Doctor(opts) => opts.writable,
})
}
#[test]
fn writable_from_flag_and_environment() {
assert!(!writable(&[], None).unwrap(), "unset is off");
assert!(writable(&["--writable"], None).unwrap(), "flag is on");
for off in ["", "false", "0", "no", "FALSE", "No"] {
assert!(!writable(&[], Some(off)).unwrap(), "{off:?} is off");
}
for on in ["true", "1", "yes", "TRUE", "Yes"] {
assert!(writable(&[], Some(on)).unwrap(), "{on:?} is on");
}
assert!(
writable(&["--writable"], Some("false")).unwrap(),
"the flag wins over the environment"
);
assert!(
writable(&[], Some("maybe")).is_err(),
"other values are refused"
);
writable(&[], None).unwrap();
}
}