use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use serde::Deserialize;
use std::{
fs,
net::IpAddr,
path::{Path, PathBuf},
};
#[derive(Debug, Parser)]
#[command(
name = "fs-mcp-rs",
version,
about = "Fast, secure filesystem Model Context Protocol (MCP) server",
long_about = "fs-mcp-rs is a high-performance Model Context Protocol (MCP) server providing bounded filesystem operations, parallel search, and optional terminal execution.\n\nQuickstart:\n fs-mcp-rs init Run interactive setup wizard (arrow keys)\n fs-mcp-rs serve Start HTTP server (auto-detects config.toml)\n fs-mcp-rs config snippet Generate configuration for Claude Desktop / Cursor\n fs-mcp-rs tools List all available MCP tools\n"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Commands>,
#[arg(long, short, env = "FS_MCP_CONFIG", value_name = "FILE", global = true)]
pub config: Option<PathBuf>,
#[arg(long, global = true)]
pub stdio: bool,
#[arg(value_name = "PATHS")]
pub root_paths: Vec<PathBuf>,
}
#[derive(Debug, Subcommand)]
pub enum Commands {
Serve {
#[arg(long, short, value_name = "FILE")]
config: Option<PathBuf>,
#[arg(long)]
stdio: bool,
},
Init {
#[arg(long, short, value_name = "FILE", default_value = "config.toml")]
output: PathBuf,
},
Config {
#[command(subcommand)]
command: ConfigCommands,
},
Tools,
}
#[derive(Debug, Subcommand)]
pub enum ConfigCommands {
PrintExample,
Snippet {
#[arg(long, short, value_name = "FILE")]
config: Option<PathBuf>,
},
Check {
#[arg(long, short, value_name = "FILE")]
config: Option<PathBuf>,
},
}
pub fn resolve_config_path(explicit: Option<&Path>) -> Option<PathBuf> {
if let Some(path) = explicit {
return Some(path.to_path_buf());
}
if let Ok(env_path) = std::env::var("FS_MCP_CONFIG") {
if !env_path.trim().is_empty() {
return Some(PathBuf::from(env_path));
}
}
let candidates = [
PathBuf::from("config.toml"),
PathBuf::from("configs/default.toml"),
PathBuf::from("configs/example.toml"),
];
candidates.into_iter().find(|candidate| candidate.is_file())
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub server: Server,
pub filesystem: Filesystem,
pub search: Search,
pub terminal: Terminal,
#[serde(default)]
pub oauth: OAuthSettings,
}
#[derive(Clone, Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct OAuthSettings {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub require_auth: bool,
#[serde(default)]
pub issuer: Option<String>,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Server {
pub host: IpAddr,
pub port: u16,
pub max_concurrency: usize,
pub max_io_concurrency: usize,
#[serde(default = "default_true")]
pub log_tools: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Filesystem {
pub roots: Vec<PathBuf>,
pub read_only: bool,
pub max_read_bytes: usize,
pub max_write_bytes: usize,
pub follow_links: bool,
#[serde(default = "default_tree_max_depth")]
pub tree_max_depth: usize,
#[serde(default = "default_tree_max_entries")]
pub tree_max_entries: usize,
#[serde(default = "default_tree_max_warnings")]
pub tree_max_warnings: usize,
#[serde(default = "default_patch_max_bytes")]
pub patch_max_bytes: usize,
#[serde(default = "default_patch_preview_bytes")]
pub patch_preview_bytes: usize,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Search {
pub max_results: usize,
pub max_concurrency: usize,
pub worker_threads: usize,
pub regex_cache_capacity: usize,
pub include_hidden: bool,
pub respect_gitignore: bool,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Terminal {
pub enabled: bool,
pub max_concurrency: usize,
pub default_timeout_ms: u64,
pub max_timeout_ms: u64,
pub max_output_bytes: usize,
#[serde(default = "default_terminal_max_read_bytes")]
pub max_read_bytes: usize,
#[serde(default = "default_terminal_max_wait_ms")]
pub max_wait_ms: u64,
#[serde(default = "default_terminal_session_retention_ms")]
pub session_retention_ms: u64,
}
fn default_true() -> bool {
true
}
fn default_tree_max_depth() -> usize {
8
}
fn default_tree_max_entries() -> usize {
1000
}
fn default_tree_max_warnings() -> usize {
32
}
fn default_patch_max_bytes() -> usize {
1048576
}
fn default_patch_preview_bytes() -> usize {
16384
}
fn default_terminal_max_read_bytes() -> usize {
262_144
}
fn default_terminal_max_wait_ms() -> u64 {
30_000
}
fn default_terminal_session_retention_ms() -> u64 {
300_000
}
impl Settings {
pub fn default_with_roots(roots: Vec<PathBuf>) -> Result<Self> {
let text = include_str!("../configs/default.toml");
let mut settings: Self = toml::from_str(text).context("invalid default configuration template")?;
if !roots.is_empty() {
settings.filesystem.roots = roots;
}
for root in &mut settings.filesystem.roots {
if root.is_relative() {
if let Ok(cwd) = std::env::current_dir() {
*root = cwd.join(&*root);
}
}
}
settings.validate()?;
Ok(settings)
}
pub fn load(path: &Path) -> Result<Self> {
let config_path = fs::canonicalize(path)
.with_context(|| format!("cannot resolve configuration file {}", path.display()))?;
let text = fs::read_to_string(&config_path)
.with_context(|| format!("cannot read {}", config_path.display()))?;
let mut settings: Self = toml::from_str(&text).context("invalid configuration")?;
let base = config_path
.parent()
.context("configuration has no parent directory")?;
for root in &mut settings.filesystem.roots {
if root.is_relative() {
*root = base.join(&*root);
}
}
settings.validate()?;
Ok(settings)
}
fn validate(&self) -> Result<()> {
if self.filesystem.roots.is_empty() {
bail!("filesystem.roots must contain at least one directory");
}
if self.server.max_concurrency == 0 || self.server.max_io_concurrency == 0 {
bail!("server concurrency limits must be greater than zero");
}
if self.search.max_concurrency == 0 || self.search.worker_threads == 0 {
bail!("search concurrency and worker_threads must be greater than zero");
}
if self.filesystem.tree_max_depth == 0
|| self.filesystem.tree_max_entries == 0
|| self.filesystem.tree_max_warnings == 0
|| self.filesystem.patch_max_bytes == 0
|| self.filesystem.patch_preview_bytes == 0
|| self.filesystem.patch_preview_bytes > self.filesystem.patch_max_bytes
{
bail!(
"filesystem tree and patch limits must be positive and patch_preview_bytes must not exceed patch_max_bytes"
);
}
if self.filesystem.max_read_bytes == 0 || self.filesystem.max_write_bytes == 0 {
bail!("filesystem byte limits must be greater than zero");
}
if self.search.max_results == 0 {
bail!("search.max_results must be greater than zero");
}
if self.terminal.max_concurrency == 0 {
bail!("terminal.max_concurrency must be greater than zero");
}
if self.terminal.default_timeout_ms == 0
|| self.terminal.max_timeout_ms == 0
|| self.terminal.default_timeout_ms > self.terminal.max_timeout_ms
{
bail!(
"terminal timeouts must be greater than zero and default_timeout_ms must not exceed max_timeout_ms"
);
}
if self.terminal.max_output_bytes == 0 || self.terminal.max_read_bytes == 0 {
bail!("terminal output and read byte limits must be greater than zero");
}
if self.terminal.max_wait_ms == 0 || self.terminal.session_retention_ms == 0 {
bail!("terminal wait and session retention limits must be greater than zero");
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_unknown_fields() {
let input = r#"
[server]
host = "127.0.0.1"
port = 8000
max_concurrency = 1
max_io_concurrency = 1
typo = true
[filesystem]
roots = ["."]
read_only = true
max_read_bytes = 1
max_write_bytes = 1
follow_links = false
[search]
max_results = 1
max_concurrency = 1
worker_threads = 1
regex_cache_capacity = 1
include_hidden = false
respect_gitignore = true
[terminal]
enabled = true
max_concurrency = 1
default_timeout_ms = 1000
max_timeout_ms = 2000
max_output_bytes = 1024
"#;
assert!(toml::from_str::<Settings>(input).is_err());
}
#[test]
fn resolves_explicit_and_default_config_path() {
let explicit = Path::new("custom.toml");
assert_eq!(
resolve_config_path(Some(explicit)),
Some(PathBuf::from("custom.toml"))
);
let parsed =
Cli::try_parse_from(["fs-mcp-rs", "init", "--output", "my_config.toml"]).unwrap();
match parsed.command {
Some(Commands::Init { output }) => assert_eq!(output, PathBuf::from("my_config.toml")),
_ => panic!("Expected Init subcommand"),
}
}
}