use std::path::{Path, PathBuf};
use std::sync::Arc;
use cap_std::ambient_authority;
use cap_std::fs::Dir as CapDir;
use rmcp::{
ErrorData as McpError,
handler::server::router::tool::ToolRouter,
handler::server::wrapper::Parameters,
model::{CallToolResult, Content},
tool, tool_router,
};
use tracing::info;
use crate::error::FsError;
use crate::tools::*;
bitflags::bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DirPerms: u8 {
const READ = 0b01;
const MUTATE = 0b10;
}
}
bitflags::bitflags! {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FilePerms: u8 {
const READ = 0b01;
const WRITE = 0b10;
}
}
pub struct FilesystemServer {
allowed_dirs: Vec<AllowedDir>,
pub tool_router: ToolRouter<Self>,
max_file_size: u64,
}
pub struct AllowedDir {
pub path: PathBuf,
pub dir: Arc<CapDir>,
pub dir_perms: DirPerms,
pub file_perms: FilePerms,
}
impl FilesystemServer {
pub fn new(allowed_paths: Vec<PathBuf>, max_file_size: u64) -> std::result::Result<Self, FsError> {
let mut allowed_dirs = Vec::new();
for path in allowed_paths {
let canonical = path.canonicalize().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
FsError::NotFound(path.display().to_string())
} else {
FsError::Io(e)
}
})?;
if !canonical.is_dir() {
return Err(FsError::NotDirectory(canonical.display().to_string()));
}
let cap_dir =
CapDir::open_ambient_dir(&canonical, ambient_authority()).map_err(FsError::Io)?;
allowed_dirs.push(AllowedDir {
path: canonical,
dir: Arc::new(cap_dir),
dir_perms: DirPerms::READ | DirPerms::MUTATE,
file_perms: FilePerms::READ | FilePerms::WRITE,
});
info!(
"Added allowed directory: {:?}",
allowed_dirs.last().unwrap().path
);
}
if allowed_dirs.is_empty() {
return Err(FsError::InvalidPath(
"No valid directories provided".to_string(),
));
}
Ok(Self {
allowed_dirs,
tool_router: Self::tool_router(),
max_file_size,
})
}
}
#[tool_router]
impl FilesystemServer {
#[tool(description = "Read the contents of a file")]
pub async fn read_file(
&self,
Parameters(args): Parameters<ReadFileArgs>,
) -> Result<CallToolResult, McpError> {
let (dir, relative, _, file_perms) = self.resolve_path(&args.path).map_err(McpError::from)?;
if !file_perms.contains(FilePerms::READ) {
return Err(FsError::PermissionDenied("Read not allowed".to_string()).into());
}
if args.head.is_some() && args.tail.is_some() {
return Err(FsError::NotSupported(
"Cannot specify both head and tail".to_string(),
).into());
}
let max_file_size = self.max_file_size;
let content: std::result::Result<String, std::io::Error> =
tokio::task::spawn_blocking(move || {
use std::io::Read;
let d: &CapDir = &dir;
let metadata = d.metadata(&relative)?;
if metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::IsADirectory,
"Is a directory",
));
}
if metadata.len() > max_file_size {
return Err(std::io::Error::other(
format!("File too large: {} bytes (max: {} bytes)", metadata.len(), max_file_size),
));
}
let mut file = d.open(&relative)?;
let mut content = String::new();
file.read_to_string(&mut content)?;
Ok(content)
})
.await
.map_err(|e: tokio::task::JoinError| {
McpError::internal_error(e.to_string(), None)
})?;
let content = content.map_err(|e| {
if e.kind() == std::io::ErrorKind::IsADirectory {
McpError::invalid_params(format!("Not a file: {}", args.path), None)
} else {
McpError::internal_error(e.to_string(), None)
}
})?;
let result = if let Some(n) = args.tail {
let lines: Vec<&str> = content.lines().collect();
let start = if lines.len() > n { lines.len() - n } else { 0 };
lines[start..].join("\n")
} else if let Some(n) = args.head {
let lines: Vec<&str> = content.lines().take(n).collect();
lines.join("\n")
} else {
content
};
Ok(CallToolResult::success(vec![Content::text(result)]))
}
#[tool(description = "Write content to a file")]
pub async fn write_file(
&self,
Parameters(args): Parameters<WriteFileArgs>,
) -> Result<CallToolResult, McpError> {
let (dir, relative, _, file_perms) = self.resolve_path(&args.path).map_err(McpError::from)?;
let relative = if relative.is_empty() { ".".to_string() } else { relative };
if !file_perms.contains(FilePerms::WRITE) {
return Err(FsError::PermissionDenied("Write not allowed".to_string()).into());
}
let parent_str = Path::new(&relative)
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.display().to_string());
let content = args.content.into_bytes();
let result: std::result::Result<std::io::Result<()>, tokio::task::JoinError> =
tokio::task::spawn_blocking(move || {
let d: &CapDir = &dir;
if let Some(ref parent) = parent_str {
d.create_dir_all(parent)?;
}
use std::io::Write;
let mut file = d.create(&relative)?;
file.write_all(&content)?;
Ok(())
})
.await;
result
.map_err(|e| McpError::internal_error(e.to_string(), None))?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Successfully wrote to {}",
args.path
))]))
}
#[tool(description = "List contents of a directory")]
pub async fn list_directory(
&self,
Parameters(args): Parameters<ListDirectoryArgs>,
) -> Result<CallToolResult, McpError> {
let (dir, relative, dir_perms, _) = self.resolve_path(&args.path).map_err(McpError::from)?;
if !dir_perms.contains(DirPerms::READ) {
return Err(FsError::PermissionDenied("Read not allowed".to_string()).into());
}
let relative = if relative.is_empty() { ".".to_string() } else { relative };
let result: std::result::Result<
std::io::Result<Vec<cap_std::fs::DirEntry>>,
tokio::task::JoinError,
> = tokio::task::spawn_blocking(move || {
let d: &CapDir = &dir;
d.read_dir(&relative)
.map(|entries: cap_std::fs::ReadDir| {
entries.filter_map(|e| e.ok()).collect::<Vec<_>>()
})
})
.await;
let entries = result
.map_err(|e| McpError::internal_error(e.to_string(), None))?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let mut result = Vec::new();
for entry in entries {
let file_type =
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
"[DIR]"
} else {
"[FILE]"
};
result.push(format!(
"{} {}",
file_type,
entry.file_name().to_string_lossy()
));
}
result.sort();
Ok(CallToolResult::success(vec![Content::text(
result.join("\n"),
)]))
}
#[tool(description = "Create a new directory")]
pub async fn create_directory(
&self,
Parameters(args): Parameters<CreateDirectoryArgs>,
) -> Result<CallToolResult, McpError> {
let (dir, relative, dir_perms, _) = self.resolve_path(&args.path).map_err(McpError::from)?;
let relative = if relative.is_empty() { ".".to_string() } else { relative };
if !dir_perms.contains(DirPerms::MUTATE) {
return Err(FsError::PermissionDenied(
"Create directory not allowed".to_string(),
).into());
}
let result: std::result::Result<std::io::Result<()>, tokio::task::JoinError> =
tokio::task::spawn_blocking(move || {
let dir: &CapDir = &dir;
dir.create_dir_all(&relative)
})
.await;
result
.map_err(|e| McpError::internal_error(e.to_string(), None))?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Successfully created directory {}",
args.path
))]))
}
#[tool(description = "Get file or directory information")]
pub async fn get_file_info(
&self,
Parameters(args): Parameters<GetFileInfoArgs>,
) -> Result<CallToolResult, McpError> {
let (dir, relative, _, _) = self.resolve_path(&args.path).map_err(McpError::from)?;
let relative_clone = relative.clone();
let result: std::result::Result<
std::io::Result<cap_fs_ext::Metadata>,
tokio::task::JoinError,
> = tokio::task::spawn_blocking(move || {
let dir: &CapDir = &dir;
dir.metadata(&relative)
})
.await;
let metadata = result
.map_err(|e: tokio::task::JoinError| {
McpError::internal_error(e.to_string(), None)
})?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let file_type = if metadata.is_dir() {
"directory"
} else if metadata.is_file() {
"file"
} else if metadata.is_symlink() {
"symlink"
} else {
"unknown"
};
let info = serde_json::json!({
"name": Path::new(&relative_clone)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(),
"path": args.path,
"type": file_type,
"size": metadata.len(),
"created": metadata.created().ok().map(format_time),
"modified": metadata.modified().ok().map(format_time),
"accessed": metadata.accessed().ok().map(format_time),
});
Ok(CallToolResult::success(vec![Content::text(
serde_json::to_string_pretty(&info).unwrap(),
)]))
}
#[tool(description = "Move a file from source to destination")]
pub async fn move_file(
&self,
Parameters(args): Parameters<MoveFileArgs>,
) -> Result<CallToolResult, McpError> {
let (source_dir, source_relative, source_dir_perms, _) =
self.resolve_path(&args.source).map_err(McpError::from)?;
let (dest_dir, dest_relative, dest_dir_perms, _) = self.resolve_path(&args.destination).map_err(McpError::from)?;
if !source_dir_perms.contains(DirPerms::MUTATE) {
return Err(FsError::PermissionDenied(
"Move not allowed from source".to_string(),
).into());
}
if !dest_dir_perms.contains(DirPerms::MUTATE) {
return Err(FsError::PermissionDenied(
"Move not allowed to destination".to_string(),
).into());
}
let same_dir = Arc::ptr_eq(&source_dir, &dest_dir);
let result: std::result::Result<std::io::Result<()>, tokio::task::JoinError> =
tokio::task::spawn_blocking(move || {
if same_dir {
let dir: &CapDir = &source_dir;
dir.rename(&source_relative, dir, &dest_relative)
} else {
let src: &CapDir = &source_dir;
let metadata = src.metadata(&source_relative)?;
if metadata.is_dir() {
return Err(std::io::Error::new(
std::io::ErrorKind::IsADirectory,
"Cannot move directories across different allowed directories",
));
}
use std::io::{Read, Write};
let mut source_file = src.open(&source_relative)?;
let mut content = Vec::new();
source_file.read_to_end(&mut content)?;
let dst: &CapDir = &dest_dir;
let mut dest_file = dst.create(&dest_relative)?;
dest_file.write_all(&content)?;
src.remove_file(&source_relative)?;
Ok(())
}
})
.await;
result
.map_err(|e| McpError::internal_error(e.to_string(), None))?
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
Ok(CallToolResult::success(vec![Content::text(format!(
"Successfully moved {} to {}",
args.source, args.destination
))]))
}
#[tool(description = "Search for files matching a glob pattern")]
pub async fn search_files(
&self,
Parameters(args): Parameters<SearchFilesArgs>,
) -> Result<CallToolResult, McpError> {
let (dir, relative, _, _) = self.resolve_path(&args.path).map_err(McpError::from)?;
let pattern =
glob::Pattern::new(&args.pattern).map_err(|e| McpError::invalid_params(e.to_string(), None))?;
let exclude_patterns = args.exclude_patterns.clone();
let dir_clone = dir.clone();
let relative_clone = relative.clone();
let results: std::result::Result<Vec<String>, std::io::Error> =
tokio::task::spawn_blocking(move || {
let mut results = Vec::new();
let base_path = if relative_clone.is_empty() {
PathBuf::from(".")
} else {
PathBuf::from(&relative_clone)
};
search_in_dir(
&dir_clone,
&base_path,
&pattern,
&exclude_patterns,
&mut results,
)?;
Ok(results)
})
.await
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let results = results.map_err(|e| McpError::internal_error(e.to_string(), None))?;
if results.is_empty() {
return Ok(CallToolResult::success(vec![Content::text(
"No matches found".to_string(),
)]));
}
Ok(CallToolResult::success(vec![Content::text(
results.join("\n"),
)]))
}
#[tool(description = "List all allowed directories")]
pub fn list_allowed_directories_tool(&self) -> Result<CallToolResult, McpError> {
let dirs = self.list_allowed_directories();
Ok(CallToolResult::success(vec![Content::text(format!(
"Allowed directories:\n{}",
dirs.join("\n")
))]))
}
}
impl FilesystemServer {
pub fn resolve_path(
&self,
input: &str,
) -> Result<(Arc<CapDir>, String, DirPerms, FilePerms), FsError> {
let expanded = shellexpand::tilde(input).to_string();
let path = PathBuf::from(&expanded);
let absolute = if path.is_absolute() {
path
} else {
std::env::current_dir().map_err(FsError::Io)?.join(&path)
};
let canonical = match absolute.canonicalize() {
Ok(c) => c,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
if let Some(parent) = absolute.parent()
&& let Ok(parent_canonical) = parent.canonicalize()
{
for allowed in &self.allowed_dirs {
if parent_canonical.starts_with(&allowed.path) {
let file_name = absolute.file_name().unwrap_or_default();
let canonical_path = parent_canonical.join(file_name);
let relative = canonical_path
.strip_prefix(&allowed.path)
.map_err(|_| {
FsError::PathNotAllowed(input.to_string())
})?;
return Ok((
allowed.dir.clone(),
relative.display().to_string(),
allowed.dir_perms,
allowed.file_perms,
));
}
}
}
return Err(FsError::NotFound(input.to_string()));
}
return Err(FsError::Io(e));
}
};
for allowed in &self.allowed_dirs {
if canonical.starts_with(&allowed.path) {
let relative = canonical
.strip_prefix(&allowed.path)
.map_err(|_| FsError::PathNotAllowed(input.to_string()))?;
return Ok((
allowed.dir.clone(),
relative.display().to_string(),
allowed.dir_perms,
allowed.file_perms,
));
}
}
Err(FsError::PathNotAllowed(input.to_string()))
}
pub fn list_allowed_directories(&self) -> Vec<String> {
self.allowed_dirs
.iter()
.map(|d| d.path.display().to_string())
.collect()
}
}
fn search_in_dir(
dir: &Arc<CapDir>,
current_path: &Path,
pattern: &glob::Pattern,
exclude_patterns: &[String],
results: &mut Vec<String>,
) -> std::io::Result<()> {
let entries: Vec<_> = dir.read_dir(current_path)?.filter_map(|e| e.ok()).collect();
for entry in entries {
let name = entry.file_name().to_string_lossy().to_string();
let entry_path = current_path.join(&name);
if !exclude_patterns.is_empty() {
let rel_str = entry_path.to_string_lossy();
if exclude_patterns.iter().any(|p| glob_match(p, &rel_str)) {
continue;
}
}
if pattern.matches(&name) {
results.push(entry_path.to_string_lossy().to_string());
}
if entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
search_in_dir(dir, &entry_path, pattern, exclude_patterns, results)?;
}
}
Ok(())
}