sand-mcp-fs 0.1.0

MCP filesystem server with sandbox security based on cap-std
Documentation
use rmcp::schemars::{self, JsonSchema};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ReadFileArgs {
    #[schemars(description = "The path to the file to read")]
    pub path: String,
    #[schemars(description = "Number of lines to read from the beginning")]
    #[serde(default)]
    pub head: Option<usize>,
    #[schemars(description = "Number of lines to read from the end")]
    #[serde(default)]
    pub tail: Option<usize>,
}

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct WriteFileArgs {
    #[schemars(description = "The path to write the file to")]
    pub path: String,
    #[schemars(description = "The content to write to the file")]
    pub content: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ListDirectoryArgs {
    #[schemars(description = "The path to the directory to list")]
    pub path: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct CreateDirectoryArgs {
    #[schemars(description = "The path of the directory to create")]
    pub path: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GetFileInfoArgs {
    #[schemars(description = "The path to get information for")]
    pub path: String,
}

#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct MoveFileArgs {
    #[schemars(description = "The source file path")]
    pub source: String,
    #[schemars(description = "The destination file path")]
    pub destination: String,
}

/// Search files matching a glob pattern.
///
/// The pattern is matched against file names only, not full paths.
/// The search is recursive, so files in subdirectories are also matched.
///
/// # Examples
/// - `*.txt` - matches all .txt files recursively
/// - `test*` - matches files starting with "test"
/// - `*.{rs,toml}` - not supported, use separate patterns
///
/// Note: `**` syntax is not supported. Use `exclude_patterns` to filter paths.
#[derive(Debug, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SearchFilesArgs {
    #[schemars(description = "Directory path to search in")]
    pub path: String,
    #[schemars(description = "Glob pattern to match file names (e.g., *.txt)")]
    pub pattern: String,
    #[schemars(description = "Patterns to exclude from results")]
    #[serde(default)]
    pub exclude_patterns: Vec<String>,
}

#[derive(Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct FileInfo {
    pub name: String,
    pub path: String,
    pub file_type: String,
    pub size: u64,
    pub created: Option<String>,
    pub modified: Option<String>,
    pub accessed: Option<String>,
    pub permissions: Option<String>,
}

#[derive(Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct DirectoryInfo {
    pub name: String,
    pub path: String,
    pub children: Vec<String>,
}

#[derive(Debug, Serialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct SearchResult {
    pub path: String,
    pub file_type: String,
    pub size: u64,
}

#[allow(dead_code)]
pub fn format_size(size: u64) -> String {
    const KB: u64 = 1024;
    const MB: u64 = KB * 1024;
    const GB: u64 = MB * 1024;

    if size >= GB {
        format!("{:.2} GB", size as f64 / GB as f64)
    } else if size >= MB {
        format!("{:.2} MB", size as f64 / MB as f64)
    } else if size >= KB {
        format!("{:.2} KB", size as f64 / KB as f64)
    } else {
        format!("{} bytes", size)
    }
}

pub fn format_time(time: cap_std::time::SystemTime) -> String {
    let std_time = time.into_std();
    use std::time::UNIX_EPOCH;
    let duration = std_time.duration_since(UNIX_EPOCH).unwrap_or_default();
    let secs = duration.as_secs();
    let datetime: chrono::DateTime<chrono::Utc> =
        chrono::DateTime::from_timestamp(secs as i64, 0).unwrap_or_else(chrono::Utc::now);
    datetime.format("%Y-%m-%d %H:%M:%S UTC").to_string()
}

pub fn glob_match(pattern: &str, name: &str) -> bool {
    if pattern.contains('*') {
        glob::Pattern::new(pattern)
            .map(|p| p.matches(name))
            .unwrap_or(false)
    } else {
        name.contains(pattern)
    }
}