From a19bfce8e71758d30af81902b910fec53bb0279c Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Mon, 1 Sep 2025 11:05:56 -0300 Subject: [PATCH 01/12] roots support --- src/cli.rs | 26 ++++++++-- src/fs_service.rs | 64 +++++++++++++++++++++++++ src/handler.rs | 118 +++++++++++++++++++++++++++++++++++++--------- src/main.rs | 15 ++++-- src/server.rs | 5 +- 5 files changed, 196 insertions(+), 32 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index df09c3b..1917faf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,19 +3,37 @@ use clap::{arg, command, Parser}; #[derive(Parser, Debug)] #[command(name = env!("CARGO_PKG_NAME"))] #[command(version = env!("CARGO_PKG_VERSION"))] -#[command(about = "A lightning-fast, asynchronous, and lightweight MCP server designed for efficient handling of various filesystem operations", +#[command(about = "A lightning-fast, asynchronous, and lightweight MCP server designed for efficient handling of various filesystem operations", long_about = None)] pub struct CommandArguments { #[arg( short = 'w', long, - help = "Enables read/write mode for the app, allowing both reading and writing." + help = "Enables read/write mode for the app, allowing both reading and writing. Defaults to disabled." )] pub allow_write: bool, #[arg( - help = "List of directories that are permitted for the operation.", + help = "List of directories that are permitted for the operation. It is required when 'enable-roots' is not provided OR client does not support Roots.", long_help = concat!("Provide a space-separated list of directories that are permitted for the operation.\nThis list allows multiple directories to be provided.\n\nExample: ", env!("CARGO_PKG_NAME"), " /path/to/dir1 /path/to/dir2 /path/to/dir3"), - required = true + required = false )] pub allowed_directories: Vec, + + #[arg( + short = 't', + long, + help = "Enables dynamic directory access control via Roots from the MCP client side. Defaults to disabled.\nWhen enabled, MCP clients that support Roots can dynamically update the allowed directories.\nAny directories provided by the client will completely replace the initially configured allowed directories on the server." + )] + pub enable_roots: bool, +} + +impl CommandArguments { + pub fn validate(&self) -> Result<(), String> { + if !self.enable_roots && self.allowed_directories.is_empty() { + return Err( + format!(" is required when `--enable-roots` is not provided.\n Run `{} --help` to view the usage instructions.",env!("CARGO_PKG_NAME")) + ); + } + Ok(()) + } } diff --git a/src/fs_service.rs b/src/fs_service.rs index 32bfcb0..f9aecab 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -7,10 +7,12 @@ use grep::{ searcher::{sinks::UTF8, BinaryDetection, Searcher}, }; use serde_json::{json, Value}; +use url::Url; use std::{ env, fs::{self}, + io, path::{Path, PathBuf}, }; @@ -85,6 +87,68 @@ impl FileSystemService { } impl FileSystemService { + pub fn valid_roots(&self, roots: Vec<&str>) -> ServiceResult<(Vec, Option)> { + let paths: Vec> = roots + .iter() + .map(|p| self.parse_file_path(p)) + .collect::>(); + + // Partition into Ok and Err results + let (ok_paths, err_paths): ( + Vec>, + Vec>, + ) = paths.into_iter().partition(|p| p.is_ok()); + + let (valid_roots, no_dir_roots): (Vec, Vec) = ok_paths + .into_iter() + .collect::, _>>()? + .into_iter() + .map(|p| expand_home(p)) + .partition(|path| path.is_dir()); + + let skipped_roots = if !err_paths.is_empty() || no_dir_roots.is_empty() { + Some(format!( + "Warning: skipped {} invalid roots.", + err_paths.len() + no_dir_roots.len() + )) + } else { + None + }; + + Ok((valid_roots, skipped_roots)) + } + + pub fn update_allowed_paths(&self, valid_roots: Vec) { + // self.allowed_path = valid_roots; + } + + /// Converts a string to a `PathBuf`, supporting both raw paths and `file://` URIs. + fn parse_file_path(&self, input: &str) -> ServiceResult { + if input.starts_with("file://") { + let url = Url::parse(input).map_err(|e| { + io::Error::new(io::ErrorKind::InvalidInput, format!("Invalid URI: {}", e)) + })?; + + if url.scheme() != "file" { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "Only file:// URIs are supported", + ) + .into()); + } + + url.to_file_path().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "Failed to convert file:// URI to file path", + ) + .into() + }) + } else { + Ok(PathBuf::from(input)) + } + } + pub fn validate_path(&self, requested_path: &Path) -> ServiceResult { // Expand ~ to home directory let expanded_path = expand_home(requested_path.to_path_buf()); diff --git a/src/handler.rs b/src/handler.rs index da3ef8a..07eafd9 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -1,4 +1,5 @@ use std::cmp::Ordering; +use std::sync::Arc; use crate::cli::CommandArguments; use crate::error::ServiceError; @@ -11,17 +12,19 @@ use rust_mcp_sdk::schema::{ }; use rust_mcp_sdk::McpServer; -pub struct MyServerHandler { +pub struct FileSystemHandler { readonly: bool, - fs_service: FileSystemService, + mcp_roots_support: bool, + fs_service: Arc, } -impl MyServerHandler { +impl FileSystemHandler { pub fn new(args: &CommandArguments) -> ServiceResult { let fs_service = FileSystemService::try_new(&args.allowed_directories)?; Ok(Self { - fs_service, - readonly: !&args.allow_write, + fs_service: Arc::new(fs_service), + readonly: !args.allow_write, + mcp_roots_support: args.enable_roots, }) } @@ -33,35 +36,104 @@ impl MyServerHandler { } } - pub fn startup_message(&self) -> String { - format!( - "Secure MCP Filesystem Server running in \"{}\" mode.\nAllowed directories:\n{}", + pub async fn startup_message(&self) -> String { + let common_message = format!( + "Secure MCP Filesystem Server running in \"{}\" mode {} \"MCP Roots\" support.", if !self.readonly { "read/write" } else { "readonly" }, - self.fs_service - .allowed_directories() - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(",\n") - ) + if self.mcp_roots_support { + "with" + } else { + "without" + }, + ); + + let sub_message: String; + + let allowed_directories = self.fs_service.allowed_directories(); + if allowed_directories.is_empty() && self.mcp_roots_support { + sub_message = "No allowed directories is set - waiting for client to provide roots via MCP protocol...".to_string(); + } else { + sub_message = format!( + "Allowed directories:\n{}", + allowed_directories + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(",\n") + ); + } + + format!("{common_message}\n{sub_message}") + } + + pub(crate) async fn update_allowed_directories(&self, runtime: Arc) { + // if client does not support roots + if !runtime.client_supports_root_list().unwrap_or(false) { + let allowed_directories = self.fs_service.allowed_directories(); + if !allowed_directories.is_empty() { + let _ = runtime.stderr_message(format!("Client does not support MCP Roots, using allowed directories set from server args:\n{}", allowed_directories + .iter() + .map(|p| p.display().to_string()) + .collect::>() + .join(",\n"))).await; + } else { + // let message = "Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories."; + let message = "Server cannot operate: No allowed directories available. Server was started without command-line directories and client does not support MCP roots protocol. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories."; + let _ = runtime.stderr_message(message.to_string()).await; + // runtime.shutdown().await; + } + } else { + let fs_service = self.fs_service.clone(); + // retreive roots from the client and update the allowed dirctories accordingly + tokio::spawn(async move { + let roots = match runtime.clone().list_roots(None).await { + Ok(roots_result) => roots_result.roots, + Err(_err) => { + vec![] + } + }; + + let valid_roots = if roots.is_empty() { + vec![] + } else { + let roots: Vec<_> = roots.iter().map(|v| v.uri.as_str()).collect(); + let valid_roots = match fs_service.valid_roots(roots) { + Ok((roots, skipped)) => { + if let Some(message) = skipped { + let _ = runtime.stderr_message(message.to_string()).await; + } + roots + } + Err(_err) => vec![], + }; + valid_roots + }; + + if valid_roots.is_empty() { + let message = "Server cannot operate: No allowed directories available. Server was started without command-line directories and client provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories."; + let _ = runtime.stderr_message(message.to_string()).await; + } else { + fs_service.update_allowed_paths(valid_roots); + } + }); + } } } #[async_trait] -impl ServerHandler for MyServerHandler { - async fn on_server_started(&self, runtime: &dyn McpServer) { - let _ = runtime.stderr_message(self.startup_message()).await; +impl ServerHandler for FileSystemHandler { + async fn on_initialized(&self, runtime: Arc) { + let _ = runtime.stderr_message(self.startup_message().await).await; + self.update_allowed_directories(runtime).await; } - async fn on_initialized(&self, _: &dyn McpServer) {} - async fn handle_list_tools_request( &self, _: ListToolsRequest, - _: &dyn McpServer, + _: Arc, ) -> std::result::Result { Ok(ListToolsResult { tools: FileSystemTools::tools(), @@ -73,7 +145,7 @@ impl ServerHandler for MyServerHandler { async fn handle_initialize_request( &self, initialize_request: InitializeRequest, - runtime: &dyn McpServer, + runtime: Arc, ) -> std::result::Result { runtime .set_client_details(initialize_request.params.clone()) @@ -95,7 +167,7 @@ impl ServerHandler for MyServerHandler { async fn handle_call_tool_request( &self, request: CallToolRequest, - _: &dyn McpServer, + _: Arc, ) -> std::result::Result { let tool_params: FileSystemTools = FileSystemTools::try_from(request.params).map_err(CallToolError::new)?; diff --git a/src/main.rs b/src/main.rs index fadeb83..ef0e08e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,16 @@ use clap::Parser; -use rust_mcp_filesystem::{cli, error::ServiceResult, server}; +use rust_mcp_filesystem::{cli, server}; #[tokio::main] -async fn main() -> ServiceResult<()> { - server::start_server(cli::CommandArguments::parse()).await +async fn main() { + let arguments = cli::CommandArguments::parse(); + if let Err(err) = arguments.validate() { + eprintln!("Error: {err}"); + return; + }; + + if let Err(error) = server::start_server(arguments).await { + eprintln!("{error}"); + } + println!(">>> 90 {:?} ", 90); } diff --git a/src/server.rs b/src/server.rs index af7151e..371c678 100644 --- a/src/server.rs +++ b/src/server.rs @@ -4,7 +4,8 @@ use rust_mcp_sdk::schema::{ }; use rust_mcp_sdk::{mcp_server::server_runtime, McpServer, StdioTransport, TransportOptions}; -use crate::{cli::CommandArguments, error::ServiceResult, handler::MyServerHandler}; +use crate::handler::FileSystemHandler; +use crate::{cli::CommandArguments, error::ServiceResult}; pub fn server_details() -> InitializeResult { InitializeResult { @@ -30,7 +31,7 @@ pub fn server_details() -> InitializeResult { pub async fn start_server(args: CommandArguments) -> ServiceResult<()> { let transport = StdioTransport::new(TransportOptions::default())?; - let handler = MyServerHandler::new(&args)?; + let handler = FileSystemHandler::new(&args)?; let server = server_runtime::create_server(server_details(), transport, handler); server.start().await?; From 1d9baf1241fe7d20ccb532e78ab747f494622d4f Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Mon, 1 Sep 2025 12:39:43 -0300 Subject: [PATCH 02/12] feat: handle roots from client --- src/fs_service.rs | 165 +++++++++++++++----------- src/handler.rs | 32 ++++- src/tools/directory_tree.rs | 4 + src/tools/list_allowed_directories.rs | 1 + src/tools/search_file.rs | 1 + src/tools/search_files_content.rs | 17 +-- 6 files changed, 135 insertions(+), 85 deletions(-) diff --git a/src/fs_service.rs b/src/fs_service.rs index f9aecab..f650d68 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -7,13 +7,13 @@ use grep::{ searcher::{sinks::UTF8, BinaryDetection, Searcher}, }; use serde_json::{json, Value}; -use url::Url; use std::{ + collections::HashSet, env, fs::{self}, - io, path::{Path, PathBuf}, + sync::Arc, }; use async_zip::tokio::{read::seek::ZipFileReader, write::ZipFileWriter}; @@ -23,6 +23,7 @@ use similar::TextDiff; use tokio::{ fs::File, io::{AsyncWriteExt, BufReader}, + sync::RwLock, }; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; use utils::{ @@ -40,7 +41,7 @@ const SNIPPET_MAX_LENGTH: usize = 200; const SNIPPET_BACKWARD_CHARS: usize = 30; pub struct FileSystemService { - allowed_path: Vec, + allowed_path: RwLock>>, } /// Represents a single match found in a file's content. @@ -77,12 +78,13 @@ impl FileSystemService { .collect(); Ok(Self { - allowed_path: normalized_dirs, + allowed_path: RwLock::new(Arc::new(normalized_dirs)), }) } - pub fn allowed_directories(&self) -> &Vec { - &self.allowed_path + pub async fn allowed_directories(&self) -> Arc> { + let guard = self.allowed_path.read().await; + guard.clone() } } @@ -99,14 +101,15 @@ impl FileSystemService { Vec>, ) = paths.into_iter().partition(|p| p.is_ok()); - let (valid_roots, no_dir_roots): (Vec, Vec) = ok_paths + // using HashSet to remove duplicates + let (valid_roots, no_dir_roots): (HashSet, HashSet) = ok_paths .into_iter() .collect::, _>>()? .into_iter() .map(|p| expand_home(p)) .partition(|path| path.is_dir()); - let skipped_roots = if !err_paths.is_empty() || no_dir_roots.is_empty() { + let skipped_roots = if !err_paths.is_empty() || !no_dir_roots.is_empty() { Some(format!( "Warning: skipped {} invalid roots.", err_paths.len() + no_dir_roots.len() @@ -115,41 +118,28 @@ impl FileSystemService { None }; + let valid_roots = valid_roots.into_iter().collect(); + Ok((valid_roots, skipped_roots)) } - pub fn update_allowed_paths(&self, valid_roots: Vec) { - // self.allowed_path = valid_roots; + pub async fn update_allowed_paths(&self, valid_roots: Vec) { + let mut guard = self.allowed_path.write().await; + *guard = Arc::new(valid_roots) } /// Converts a string to a `PathBuf`, supporting both raw paths and `file://` URIs. fn parse_file_path(&self, input: &str) -> ServiceResult { - if input.starts_with("file://") { - let url = Url::parse(input).map_err(|e| { - io::Error::new(io::ErrorKind::InvalidInput, format!("Invalid URI: {}", e)) - })?; - - if url.scheme() != "file" { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "Only file:// URIs are supported", - ) - .into()); - } - - url.to_file_path().map_err(|_| { - io::Error::new( - io::ErrorKind::InvalidInput, - "Failed to convert file:// URI to file path", - ) - .into() - }) - } else { - Ok(PathBuf::from(input)) - } + Ok(PathBuf::from( + input.strip_prefix("file://").unwrap_or(input).trim(), + )) } - pub fn validate_path(&self, requested_path: &Path) -> ServiceResult { + pub fn validate_path( + &self, + requested_path: &Path, + allowed_directories: Arc>, + ) -> ServiceResult { // Expand ~ to home directory let expanded_path = expand_home(requested_path.to_path_buf()); @@ -164,7 +154,7 @@ impl FileSystemService { let normalized_requested = normalize_path(&absolute_path); // Check if path is within allowed directories - if !self.allowed_path.iter().any(|dir| { + if !allowed_directories.iter().any(|dir| { // Must account for both scenarios — the requested path may not exist yet, making canonicalization impossible. normalized_requested.starts_with(dir) || normalized_requested.starts_with(normalize_path(dir)) @@ -178,7 +168,7 @@ impl FileSystemService { "Access denied - {} is outside allowed directories: {} not in {}", symlink_target, absolute_path.display(), - self.allowed_path + allowed_directories .iter() .map(|p| p.display().to_string()) .collect::>() @@ -191,7 +181,8 @@ impl FileSystemService { // Get file stats pub async fn get_file_stats(&self, file_path: &Path) -> ServiceResult { - let valid_path = self.validate_path(file_path)?; + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; let metadata = fs::metadata(valid_path)?; @@ -229,7 +220,9 @@ impl FileSystemService { pattern: String, target_zip_file: String, ) -> ServiceResult { - let valid_dir_path = self.validate_path(Path::new(&input_dir))?; + let allowed_directories = self.allowed_directories().await; + let valid_dir_path = + self.validate_path(Path::new(&input_dir), allowed_directories.clone())?; let input_dir_str = &valid_dir_path .as_os_str() @@ -239,7 +232,8 @@ impl FileSystemService { "Invalid UTF-8 in file name", ))?; - let target_path = self.validate_path(Path::new(&target_zip_file))?; + let target_path = + self.validate_path(Path::new(&target_zip_file), allowed_directories.clone())?; if target_path.exists() { return Err(std::io::Error::new( @@ -264,13 +258,17 @@ impl FileSystemService { .filter_map(|entry| { let full_path = entry.path(); - self.validate_path(full_path).ok().and_then(|path| { - if path != valid_dir_path && glob_pattern.matches(&path.display().to_string()) { - Some(path) - } else { - None - } - }) + self.validate_path(full_path, allowed_directories.clone()) + .ok() + .and_then(|path| { + if path != valid_dir_path + && glob_pattern.matches(&path.display().to_string()) + { + Some(path) + } else { + None + } + }) }) .collect(); @@ -328,8 +326,9 @@ impl FileSystemService { ) .into()); } - - let target_path = self.validate_path(Path::new(&target_zip_file))?; + let allowed_directories = self.allowed_directories().await; + let target_path = + self.validate_path(Path::new(&target_zip_file), allowed_directories.clone())?; if target_path.exists() { return Err(std::io::Error::new( @@ -341,7 +340,7 @@ impl FileSystemService { let source_paths = input_files .iter() - .map(|p| self.validate_path(Path::new(p))) + .map(|p| self.validate_path(Path::new(p), allowed_directories.clone())) .collect::, _>>()?; let zip_file = File::create(&target_path).await?; @@ -378,8 +377,10 @@ impl FileSystemService { } pub async fn unzip_file(&self, zip_file: &str, target_dir: &str) -> ServiceResult { - let zip_file = self.validate_path(Path::new(&zip_file))?; - let target_dir_path = self.validate_path(Path::new(target_dir))?; + let allowed_directories = self.allowed_directories().await; + + let zip_file = self.validate_path(Path::new(&zip_file), allowed_directories.clone())?; + let target_dir_path = self.validate_path(Path::new(target_dir), allowed_directories)?; if !zip_file.exists() { return Err(std::io::Error::new( std::io::ErrorKind::NotFound, @@ -429,26 +430,31 @@ impl FileSystemService { } pub async fn read_file(&self, file_path: &Path) -> ServiceResult { - let valid_path = self.validate_path(file_path)?; + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; let content = tokio::fs::read_to_string(valid_path).await?; Ok(content) } pub async fn create_directory(&self, file_path: &Path) -> ServiceResult<()> { - let valid_path = self.validate_path(file_path)?; + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; tokio::fs::create_dir_all(valid_path).await?; Ok(()) } pub async fn move_file(&self, src_path: &Path, dest_path: &Path) -> ServiceResult<()> { - let valid_src_path = self.validate_path(src_path)?; - let valid_dest_path = self.validate_path(dest_path)?; + let allowed_directories = self.allowed_directories().await; + let valid_src_path = self.validate_path(src_path, allowed_directories.clone())?; + let valid_dest_path = self.validate_path(dest_path, allowed_directories)?; tokio::fs::rename(valid_src_path, valid_dest_path).await?; Ok(()) } pub async fn list_directory(&self, dir_path: &Path) -> ServiceResult> { - let valid_path = self.validate_path(dir_path)?; + let allowed_directories = self.allowed_directories().await; + + let valid_path = self.validate_path(dir_path, allowed_directories)?; let mut dir = tokio::fs::read_dir(valid_path).await?; @@ -463,7 +469,8 @@ impl FileSystemService { } pub async fn write_file(&self, file_path: &Path, content: &String) -> ServiceResult<()> { - let valid_path = self.validate_path(file_path)?; + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; tokio::fs::write(valid_path, content).await?; Ok(()) } @@ -480,13 +487,15 @@ impl FileSystemService { /// # Returns /// A `ServiceResult` containing a vector of`walkdir::DirEntry` objects for matching files, /// or a `ServiceError` if an error occurs. - pub fn search_files( + pub async fn search_files( &self, root_path: &Path, pattern: String, exclude_patterns: Vec, ) -> ServiceResult> { - let result = self.search_files_iter(root_path, pattern, exclude_patterns)?; + let result = self + .search_files_iter(root_path, pattern, exclude_patterns) + .await?; Ok(result.collect::>()) } @@ -501,14 +510,15 @@ impl FileSystemService { /// # Returns /// A `ServiceResult` containing an iterator yielding `walkdir::DirEntry` objects for matching files, /// or a `ServiceError` if an error occurs. - pub fn search_files_iter<'a>( + pub async fn search_files_iter<'a>( &'a self, // root_path: impl Into, root_path: &'a Path, pattern: String, exclude_patterns: Vec, ) -> ServiceResult + 'a> { - let valid_path = self.validate_path(root_path)?; + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(root_path, allowed_directories.clone())?; let updated_pattern = if pattern.contains('*') { pattern.to_lowercase() @@ -524,7 +534,9 @@ impl FileSystemService { let full_path = dir_entry.path(); // Validate each path before processing - let validated_path = self.validate_path(full_path).ok(); + let validated_path = self + .validate_path(full_path, allowed_directories.clone()) + .ok(); if validated_path.is_none() { // Skip invalid paths during search @@ -586,8 +598,9 @@ impl FileSystemService { max_depth: Option, max_files: Option, current_count: &mut usize, + allowed_directories: Arc>, ) -> ServiceResult<(Value, bool)> { - let valid_path = self.validate_path(root_path.as_ref())?; + let valid_path = self.validate_path(root_path.as_ref(), allowed_directories.clone())?; let metadata = fs::metadata(&valid_path)?; if !metadata.is_dir() { @@ -633,8 +646,13 @@ impl FileSystemService { if metadata.is_dir() { let next_depth = max_depth.map(|d| d - 1); - let (child_children, child_reached_max_depth) = - self.directory_tree(child_path, next_depth, max_files, current_count)?; + let (child_children, child_reached_max_depth) = self.directory_tree( + child_path, + next_depth, + max_files, + current_count, + allowed_directories.clone(), + )?; json_entry .as_object_mut() .unwrap() @@ -684,7 +702,8 @@ impl FileSystemService { dry_run: Option, save_to: Option<&Path>, ) -> ServiceResult { - let valid_path = self.validate_path(file_path)?; + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; // Read file content and normalize line endings let content_str = tokio::fs::read_to_string(&valid_path).await?; @@ -987,7 +1006,7 @@ impl FileSystemService { result } - pub fn search_files_content( + pub async fn search_files_content( &self, root_path: impl AsRef, pattern: &str, @@ -995,11 +1014,13 @@ impl FileSystemService { is_regex: bool, exclude_patterns: Option>, ) -> ServiceResult> { - let files_iter = self.search_files_iter( - root_path.as_ref(), - pattern.to_string(), - exclude_patterns.to_owned().unwrap_or_default(), - )?; + let files_iter = self + .search_files_iter( + root_path.as_ref(), + pattern.to_string(), + exclude_patterns.to_owned().unwrap_or_default(), + ) + .await?; let results: Vec = files_iter .filter_map(|entry| { diff --git a/src/handler.rs b/src/handler.rs index 07eafd9..bfb3c44 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -6,6 +6,7 @@ use crate::error::ServiceError; use crate::{error::ServiceResult, fs_service::FileSystemService, tools::*}; use async_trait::async_trait; use rust_mcp_sdk::mcp_server::ServerHandler; +use rust_mcp_sdk::schema::RootsListChangedNotification; use rust_mcp_sdk::schema::{ schema_utils::CallToolError, CallToolRequest, CallToolResult, InitializeRequest, InitializeResult, ListToolsRequest, ListToolsResult, RpcError, @@ -53,7 +54,7 @@ impl FileSystemHandler { let sub_message: String; - let allowed_directories = self.fs_service.allowed_directories(); + let allowed_directories = self.fs_service.allowed_directories().await; if allowed_directories.is_empty() && self.mcp_roots_support { sub_message = "No allowed directories is set - waiting for client to provide roots via MCP protocol...".to_string(); } else { @@ -72,8 +73,8 @@ impl FileSystemHandler { pub(crate) async fn update_allowed_directories(&self, runtime: Arc) { // if client does not support roots + let allowed_directories = self.fs_service.allowed_directories().await; if !runtime.client_supports_root_list().unwrap_or(false) { - let allowed_directories = self.fs_service.allowed_directories(); if !allowed_directories.is_empty() { let _ = runtime.stderr_message(format!("Client does not support MCP Roots, using allowed directories set from server args:\n{}", allowed_directories .iter() @@ -84,10 +85,10 @@ impl FileSystemHandler { // let message = "Server cannot operate: No allowed directories available. Server was started without command-line directories and client either does not support MCP roots protocol or provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories."; let message = "Server cannot operate: No allowed directories available. Server was started without command-line directories and client does not support MCP roots protocol. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories."; let _ = runtime.stderr_message(message.to_string()).await; - // runtime.shutdown().await; } } else { let fs_service = self.fs_service.clone(); + let mcp_roots_support = self.mcp_roots_support; // retreive roots from the client and update the allowed dirctories accordingly tokio::spawn(async move { let roots = match runtime.clone().list_roots(None).await { @@ -113,11 +114,21 @@ impl FileSystemHandler { valid_roots }; - if valid_roots.is_empty() { - let message = "Server cannot operate: No allowed directories available. Server was started without command-line directories and client provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories."; + if valid_roots.is_empty() && !mcp_roots_support { + let message = if allowed_directories.is_empty() { + "Server cannot operate: No allowed directories available. Server was started without command-line directories and client provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories." + } else { + "Client provided empty roots. Allowed directories passed from command-line will be used." + }; let _ = runtime.stderr_message(message.to_string()).await; } else { - fs_service.update_allowed_paths(valid_roots); + let num_valid_roots = valid_roots.len(); + + fs_service.update_allowed_paths(valid_roots).await; + let message = format!( + "Updated allowed directories from MCP roots: {num_valid_roots} valid directories", + ); + let _ = runtime.stderr_message(message.to_string()).await; } }); } @@ -130,6 +141,15 @@ impl ServerHandler for FileSystemHandler { self.update_allowed_directories(runtime).await; } + async fn handle_roots_list_changed_notification( + &self, + _notification: RootsListChangedNotification, + runtime: Arc, + ) -> std::result::Result<(), RpcError> { + self.update_allowed_directories(runtime).await; + Ok(()) + } + async fn handle_list_tools_request( &self, _: ListToolsRequest, diff --git a/src/tools/directory_tree.rs b/src/tools/directory_tree.rs index c6555b7..b9d876b 100644 --- a/src/tools/directory_tree.rs +++ b/src/tools/directory_tree.rs @@ -33,12 +33,16 @@ impl DirectoryTreeTool { context: &FileSystemService, ) -> std::result::Result { let mut entry_counter: usize = 0; + + let allowed_directories = context.allowed_directories().await; + let (entries, reached_max_depth) = context .directory_tree( params.path, params.max_depth.map(|v| v as usize), None, &mut entry_counter, + allowed_directories, ) .map_err(CallToolError::new)?; diff --git a/src/tools/list_allowed_directories.rs b/src/tools/list_allowed_directories.rs index 36e52c2..6abb0f4 100644 --- a/src/tools/list_allowed_directories.rs +++ b/src/tools/list_allowed_directories.rs @@ -28,6 +28,7 @@ impl ListAllowedDirectoriesTool { "Allowed directories:\n{}", context .allowed_directories() + .await .iter() .map(|entry| entry.display().to_string()) .collect::>() diff --git a/src/tools/search_file.rs b/src/tools/search_file.rs index dfee718..627b4f8 100644 --- a/src/tools/search_file.rs +++ b/src/tools/search_file.rs @@ -41,6 +41,7 @@ impl SearchFilesTool { params.pattern, params.exclude_patterns.unwrap_or_default(), ) + .await .map_err(CallToolError::new)?; let result = if !list.is_empty() { diff --git a/src/tools/search_files_content.rs b/src/tools/search_files_content.rs index e1e50e9..c612a23 100644 --- a/src/tools/search_files_content.rs +++ b/src/tools/search_files_content.rs @@ -65,13 +65,16 @@ impl SearchFilesContentTool { context: &FileSystemService, ) -> std::result::Result { let is_regex = params.is_regex.unwrap_or_default(); - match context.search_files_content( - ¶ms.path, - ¶ms.pattern, - ¶ms.query, - is_regex, - params.exclude_patterns.to_owned(), - ) { + match context + .search_files_content( + ¶ms.path, + ¶ms.pattern, + ¶ms.query, + is_regex, + params.exclude_patterns.to_owned(), + ) + .await + { Ok(results) => { if results.is_empty() { return Ok(CallToolResult::with_error(CallToolError::new( From ed6a41a31cf88a9af30c7f0fe6a4be5b2ee7c84b Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Mon, 1 Sep 2025 12:47:37 -0300 Subject: [PATCH 03/12] chore: better messaging when allowed_directory list is empty --- src/fs_service.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/fs_service.rs b/src/fs_service.rs index f650d68..264fadf 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -140,6 +140,12 @@ impl FileSystemService { requested_path: &Path, allowed_directories: Arc>, ) -> ServiceResult { + if allowed_directories.is_empty() { + return Err(ServiceError::FromString(format!( + "Allowed directories list is empty. Client did not provide any valid root directories." + ))); + } + // Expand ~ to home directory let expanded_path = expand_home(requested_path.to_path_buf()); From ed64e8598c96f14d17ea40bf2a22ce6180e8f9b2 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Mon, 1 Sep 2025 12:50:13 -0300 Subject: [PATCH 04/12] chore: better messaging --- src/tools/list_allowed_directories.rs | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/tools/list_allowed_directories.rs b/src/tools/list_allowed_directories.rs index 6abb0f4..84a9887 100644 --- a/src/tools/list_allowed_directories.rs +++ b/src/tools/list_allowed_directories.rs @@ -24,16 +24,20 @@ impl ListAllowedDirectoriesTool { _: Self, context: &FileSystemService, ) -> std::result::Result { - let result = format!( - "Allowed directories:\n{}", - context - .allowed_directories() - .await - .iter() - .map(|entry| entry.display().to_string()) - .collect::>() - .join("\n") - ); + let allowed_directories = context.allowed_directories().await; + + let result = if allowed_directories.is_empty() { + "Allowed directories list is empty!".to_string() + } else { + format!( + "Allowed directories:\n{}", + allowed_directories + .iter() + .map(|entry| entry.display().to_string()) + .collect::>() + .join("\n") + ) + }; Ok(CallToolResult::text_content(vec![TextContent::from( result, )])) From 85e2c27e4452c5f4b3bc6256e6c3b7313cda6599 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 19 Sep 2025 16:06:10 -0300 Subject: [PATCH 05/12] update tests and cleanup --- Cargo.lock | 273 +++++++++++++++++++++------------------ Cargo.toml | 6 +- README.md | 7 +- docs/README.md | 5 +- docs/_coverpage.md | 3 +- rust-toolchain.toml | 2 +- src/fs_service.rs | 35 +++-- src/handler.rs | 12 +- tests/common/common.rs | 6 +- tests/test_cli.rs | 12 +- tests/test_fs_service.rs | 115 +++++++++-------- tests/test_tools.rs | 10 +- 12 files changed, 255 insertions(+), 231 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 654eef0..fbee212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,12 +26,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -93,22 +87,15 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bee399cc3a623ec5a2db2c5b90ee0190a2260241fbe0c023ac8f7bab426aaf8" +checksum = "977eb15ea9efd848bb8a4a1a2500347ed7f0bf794edf0dc3ddcf439f43d36b23" dependencies = [ - "bzip2", "compression-codecs", "compression-core", - "deflate64", - "flate2", "futures-core", "futures-io", - "liblzma", - "memchr", "pin-project-lite", - "zstd", - "zstd-safe", ] [[package]] @@ -159,11 +146,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" -version = "2.9.3" +version = "2.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" [[package]] name = "bstr" @@ -199,10 +192,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.34" +version = "1.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -216,23 +210,22 @@ checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "chrono" -version = "0.4.41" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "wasm-bindgen", - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "clap" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c5e4fcf9c21d2e544ca1ee9d8552de13019a42aa7dbf32747fa7aaf1df76e57" +checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" dependencies = [ "clap_builder", "clap_derive", @@ -240,9 +233,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.46" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecb53a0e6fcfb055f686001bc2e2592fa527efaf38dbe81a6a9563562e57d41" +checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" dependencies = [ "anstream", "anstyle", @@ -252,9 +245,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.45" +version = "4.5.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14cb31bb0a7d536caef2639baa7fad459e15c3144efefa6dbd1c84562c4739f6" +checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" dependencies = [ "heck", "proc-macro2", @@ -276,18 +269,15 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "compression-codecs" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7eea68f0e02c2b0aa8856e9a9478444206d4b6828728e7b0697c0f8cca265cb" +checksum = "485abf41ac0c8047c07c87c72c8fb3eb5197f6e9d7ded615dfd1a00ae00a0f64" dependencies = [ "bzip2", "compression-core", "deflate64", "flate2", - "futures-core", "liblzma", - "memchr", - "pin-project-lite", "zstd", "zstd-safe", ] @@ -319,27 +309,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da692b8d1080ea3045efaab14434d40468c3d8657e42abddfffca87b428f4c1b" -[[package]] -name = "derive_more" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "unicode-xid", -] - [[package]] name = "dirs" version = "6.0.0" @@ -358,7 +327,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -381,12 +350,12 @@ dependencies = [ [[package]] name = "errno" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -395,6 +364,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" + [[package]] name = "flate2" version = "1.1.2" @@ -527,7 +502,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", - "wasi 0.14.3+wasi-0.2.4", + "wasi 0.14.7+wasi-0.2.4", ] [[package]] @@ -642,9 +617,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "iana-time-zone" -version = "0.1.63" +version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -699,9 +674,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "852f13bec5eba4ba9afbeb93fd7c13fe56147f055939ae21c43a29a0ecb2702e" dependencies = [ "once_cell", "wasm-bindgen", @@ -741,9 +716,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "391290121bad3d37fbddad76d8f5d1c1c314cfc646d143d7e07a3086ddff0ce3" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags", "libc", @@ -751,9 +726,9 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.9.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "lock_api" @@ -767,9 +742,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.27" +version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" [[package]] name = "memchr" @@ -978,7 +953,6 @@ dependencies = [ "async_zip", "chrono", "clap", - "derive_more", "dirs", "futures", "glob", @@ -996,9 +970,9 @@ dependencies = [ [[package]] name = "rust-mcp-macros" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2079014c2b3dfa82a2dafd203121d5a28929102c5e003bf8d8019a60fc17e0d" +checksum = "b647a85c9da2eaf14e67d39cb067a8157a66bd2c0dc53ef1051a84f45edfae24" dependencies = [ "proc-macro2", "quote", @@ -1009,9 +983,9 @@ dependencies = [ [[package]] name = "rust-mcp-schema" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "098436b06bfa4b88b110d12a5567cf37fd454735ee67cab7eb48bdbea0dd0e57" +checksum = "0bb65fd293dbbfabaacba1512b3948cdd9bf31ad1f2c0fed4962052b590c5c44" dependencies = [ "serde", "serde_json", @@ -1019,11 +993,12 @@ dependencies = [ [[package]] name = "rust-mcp-sdk" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "720fb8f08c2b4525cf84c923a1199f326d58f82e37d9cf4bb751b9411b023cec" +checksum = "961ec01d0bedecf488388e6b1cf04170f9badab4927061c6592ffa385c02c6c9" dependencies = [ "async-trait", + "base64", "futures", "rust-mcp-macros", "rust-mcp-schema", @@ -1033,13 +1008,14 @@ dependencies = [ "thiserror", "tokio", "tracing", + "uuid", ] [[package]] name = "rust-mcp-transport" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc874c3f07ad7aff5e5d8eab59174377b5ac0dbe2c0ef70d09efef9cdf4649b" +checksum = "35feabc5e4667019dc262178724c94cbced6f43959af15e214b52f79243f55ed" dependencies = [ "async-trait", "bytes", @@ -1061,15 +1037,15 @@ checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustix" -version = "1.0.8" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -1101,18 +1077,28 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.225" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.225" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" dependencies = [ "proc-macro2", "quote", @@ -1121,14 +1107,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -1193,15 +1180,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b61f8f20e3a6f7e0649d825294eaf317edce30f82cf6026e7e4cb9222a7d1e" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" dependencies = [ "fastrand", "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] @@ -1322,15 +1309,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.18" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" [[package]] name = "utf8parse" @@ -1338,6 +1319,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -1356,30 +1348,40 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.3+wasi-0.2.4" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "ab10a69fbd0a177f5f649ad4d8d3305499c42bab9aef2f7ff592d0ec8f833819" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-backend" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +checksum = "0bb702423545a6007bbc368fde243ba47ca275e549c8a28617f56f6ba53b1d1c" dependencies = [ "bumpalo", "log", @@ -1391,9 +1393,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "fc65f4f411d91494355917b605e1480033152658d71f722a90647f56a70c88a0" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -1401,9 +1403,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "ffc003a991398a8ee604a401e194b6b3a39677b3173d6e74495eb51b82e99a32" dependencies = [ "proc-macro2", "quote", @@ -1414,31 +1416,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "293c37f4efa430ca14db3721dfbe48d8c33308096bd44d80ebaa775ab71ba1cf" dependencies = [ "unicode-ident", ] [[package]] name = "winapi-util" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0978bf7171b3d90bac376700cb56d606feb40f251a475a5d6634613564460b22" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.0", ] [[package]] name = "windows-core" -version = "0.61.2" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" dependencies = [ "windows-implement", "windows-interface", - "windows-link", + "windows-link 0.2.0", "windows-result", "windows-strings", ] @@ -1471,22 +1473,28 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-link" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" + [[package]] name = "windows-result" -version = "0.3.4" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] name = "windows-strings" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" dependencies = [ - "windows-link", + "windows-link 0.2.0", ] [[package]] @@ -1507,6 +1515,15 @@ dependencies = [ "windows-targets 0.53.3", ] +[[package]] +name = "windows-sys" +version = "0.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +dependencies = [ + "windows-link 0.2.0", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1529,7 +1546,7 @@ version = "0.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ - "windows-link", + "windows-link 0.1.3", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -1638,9 +1655,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "wit-bindgen" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "zstd" @@ -1662,9 +1679,9 @@ dependencies = [ [[package]] name = "zstd-sys" -version = "2.0.15+zstd.1.5.7" +version = "2.0.16+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" dependencies = [ "cc", "pkg-config", diff --git a/Cargo.toml b/Cargo.toml index 98e2727..6b5f97b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rust-mcp-filesystem" version = "0.2.2" -edition = "2021" +edition = "2024" repository = "https://github.com/rust-mcp-stack/rust-mcp-filesystem" authors = ["Ali Hashemi"] description = "Blazing-fast, asynchronous MCP server for seamless filesystem operations." @@ -15,9 +15,10 @@ license = false eula = false [dependencies] -rust-mcp-sdk = { version = "0.6", default-features = false, features = [ +rust-mcp-sdk = {version="0.7", default-features = false, features = [ "server", "macros", + "stdio", "2025_06_18", ] } @@ -25,7 +26,6 @@ thiserror = { version = "2.0" } dirs = "6.0" glob = "0.3" walkdir = "2.5" -derive_more = { version = "2.0", features = ["display", "from_str"] } similar = "=2.7" chrono = "0.4" clap = { version = "4.5", features = ["derive"] } diff --git a/README.md b/README.md index 4f0bf8f..e1925b3 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Rust MCP Filesystem -Rust MCP Filesystem is a blazingly fast, asynchronous, and lightweight MCP (Model Context Protocol) server designed for efficient handling of various filesystem operations. +Rust MCP Filesystem is a blazingly fast, asynchronous, and lightweight MCP (Model Context Protocol) server designed for efficient handling of various filesystem operations. This project is a pure Rust rewrite of the JavaScript-based `@modelcontextprotocol/server-filesystem`, offering enhanced capabilities, improved performance, and a robust feature set tailored for modern filesystem interactions. 🚀 Refer to the [project documentation](https://rust-mcp-stack.github.io/rust-mcp-filesystem) for installation and configuration instructions. @@ -14,8 +14,9 @@ This project is a pure Rust rewrite of the JavaScript-based `@modelcontextprotoc - **⚡ High Performance**: Built in Rust for speed and efficiency, leveraging asynchronous I/O to handle filesystem operations seamlessly. - **🔒 Read-Only by Default**: Starts with no write access, ensuring safety until explicitly configured otherwise. - **🔍 Advanced Glob Search**: Supports full glob pattern matching allowing precise filtering of files and directories using standard glob syntax.For example, patterns like `*.rs`, `src/**/*.txt`, and `logs/error-???.log` are valid and can be used to match specific file types, recursive directory searches, or patterned filenames. -- **📁 Nested Directories**: Improved directory creation, allowing the creation of nested directories. -- **ðŸ“Ķ Lightweight**: Standalone with no external dependencies (e.g., no Node.js, Python etc required), compiled to a single binary with a minimal resource footprint, ideal for both lightweight and extensive deployment scenarios. +- **🔄 MCP Roots support**: enabling clients to dynamically modify the list of allowed directories (disabled by default). +- **ðŸ“Ķ ZIP Archive Support**: Tools to create ZIP archives from files or directories and extract ZIP files with ease. +- **ðŸŠķ Lightweight**: Standalone with no external dependencies (e.g., no Node.js, Python etc required), compiled to a single binary with a minimal resource footprint, ideal for both lightweight and extensive deployment scenarios. #### 👉 Refer to [capabilities](https://rust-mcp-stack.github.io/rust-mcp-filesystem/#/capabilities) for a full list of tools and other capabilities. diff --git a/docs/README.md b/docs/README.md index 3ea38c8..b340427 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # Rust MCP Filesystem -Rust MCP Filesystem is a blazingly fast, asynchronous, and lightweight MCP (Model Context Protocol) server designed for efficient handling of various filesystem operations. +Rust MCP Filesystem is a blazingly fast, asynchronous, and lightweight MCP (Model Context Protocol) server designed for efficient handling of various filesystem operations. This project is a pure Rust rewrite of the JavaScript-based **@modelcontextprotocol/server-filesystem**, offering enhanced capabilities, improved performance, and a robust feature set tailored for modern filesystem interactions. Refer to the [quickstart](quickstart.md) guide for installation and configuration instructions. @@ -9,7 +9,8 @@ Refer to the [quickstart](quickstart.md) guide for installation and configuratio - **⚡ High Performance**: Built in Rust for speed and efficiency, leveraging asynchronous I/O to handle filesystem operations seamlessly. - **🔒 Read-Only by Default**: Starts with no write access, ensuring safety until explicitly configured otherwise. -- **🔍 Advanced Glob Search**: Full glob pattern matching for precise file and directory filtering (e.g., `*.rs`, `src/**/*.txt`, `logs/error-???.log`). +- **🔍 Advanced Glob Search**: Supports full glob pattern matching allowing precise filtering of files and directories using standard glob syntax.For example, patterns like `*.rs`, `src/**/*.txt`, and `logs/error-???.log` are valid and can be used to match specific file types, recursive directory searches, or patterned filenames. +- **🔄 MCP Roots support**: enabling clients to dynamically modify the list of allowed directories (disabled by default). - **ðŸ“Ķ ZIP Archive Support**: Tools to create ZIP archives from files or directories and extract ZIP files with ease. - **ðŸŠķ Lightweight**: Standalone with no external dependencies (e.g., no Node.js, Python etc required), compiled to a single binary with a minimal resource footprint, ideal for both lightweight and extensive deployment scenarios. diff --git a/docs/_coverpage.md b/docs/_coverpage.md index 648271f..ea75ebc 100644 --- a/docs/_coverpage.md +++ b/docs/_coverpage.md @@ -14,7 +14,8 @@ - ðŸŠķ Lightweight - ⚡ High Performance -- 🔒 Read-Only by Default +- 🔒 Secure by design +- 🔧 Packed with powerful tools [GitHub](https://github.com/rust-mcp-stack/rust-mcp-filesystem) [⚙ïļ Capabilities](capabilities.md) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 7855e6d..908d2ec 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.88.0" +channel = "1.89.0" components = ["rustfmt", "clippy"] diff --git a/src/fs_service.rs b/src/fs_service.rs index 264fadf..310414a 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -1,13 +1,20 @@ pub mod file_info; pub mod utils; +use crate::{ + error::{ServiceError, ServiceResult}, + tools::EditOperation, +}; +use async_zip::tokio::{read::seek::ZipFileReader, write::ZipFileWriter}; use file_info::FileInfo; +use glob::Pattern; use grep::{ matcher::{Match, Matcher}, regex::RegexMatcherBuilder, searcher::{sinks::UTF8, BinaryDetection, Searcher}, }; +use rust_mcp_sdk::schema::RpcError; use serde_json::{json, Value}; - +use similar::TextDiff; use std::{ collections::HashSet, env, @@ -15,11 +22,6 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; - -use async_zip::tokio::{read::seek::ZipFileReader, write::ZipFileWriter}; -use glob::Pattern; -use rust_mcp_sdk::schema::RpcError; -use similar::TextDiff; use tokio::{ fs::File, io::{AsyncWriteExt, BufReader}, @@ -32,14 +34,11 @@ use utils::{ }; use walkdir::WalkDir; -use crate::{ - error::{ServiceError, ServiceResult}, - tools::EditOperation, -}; - const SNIPPET_MAX_LENGTH: usize = 200; const SNIPPET_BACKWARD_CHARS: usize = 30; +type PathResultList = Vec>; + pub struct FileSystemService { allowed_path: RwLock>>, } @@ -96,17 +95,15 @@ impl FileSystemService { .collect::>(); // Partition into Ok and Err results - let (ok_paths, err_paths): ( - Vec>, - Vec>, - ) = paths.into_iter().partition(|p| p.is_ok()); + let (ok_paths, err_paths): (PathResultList, PathResultList) = + paths.into_iter().partition(|p| p.is_ok()); // using HashSet to remove duplicates let (valid_roots, no_dir_roots): (HashSet, HashSet) = ok_paths .into_iter() .collect::, _>>()? .into_iter() - .map(|p| expand_home(p)) + .map(expand_home) .partition(|path| path.is_dir()); let skipped_roots = if !err_paths.is_empty() || !no_dir_roots.is_empty() { @@ -141,9 +138,9 @@ impl FileSystemService { allowed_directories: Arc>, ) -> ServiceResult { if allowed_directories.is_empty() { - return Err(ServiceError::FromString(format!( - "Allowed directories list is empty. Client did not provide any valid root directories." - ))); + return Err(ServiceError::FromString( + "Allowed directories list is empty. Client did not provide any valid root directories.".to_string() + )); } // Expand ~ to home directory diff --git a/src/handler.rs b/src/handler.rs index bfb3c44..8d102f5 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -52,21 +52,19 @@ impl FileSystemHandler { }, ); - let sub_message: String; - let allowed_directories = self.fs_service.allowed_directories().await; - if allowed_directories.is_empty() && self.mcp_roots_support { - sub_message = "No allowed directories is set - waiting for client to provide roots via MCP protocol...".to_string(); + let sub_message: String = if allowed_directories.is_empty() && self.mcp_roots_support { + "No allowed directories is set - waiting for client to provide roots via MCP protocol...".to_string() } else { - sub_message = format!( + format!( "Allowed directories:\n{}", allowed_directories .iter() .map(|p| p.display().to_string()) .collect::>() .join(",\n") - ); - } + ) + }; format!("{common_message}\n{sub_message}") } diff --git a/tests/common/common.rs b/tests/common/common.rs index 0ff88f6..468d790 100644 --- a/tests/common/common.rs +++ b/tests/common/common.rs @@ -2,6 +2,7 @@ use std::{ fs::{self, File}, io::Write, path::{Path, PathBuf}, + sync::Arc, }; use clap::Parser; @@ -18,7 +19,7 @@ pub fn get_temp_dir() -> PathBuf { } // Helper to create a FileSystemService with temporary directories -pub fn setup_service(dirs: Vec) -> (PathBuf, FileSystemService) { +pub fn setup_service(dirs: Vec) -> (PathBuf, FileSystemService, Arc>) { let temp_dir = get_temp_dir(); let allowed_dirs = dirs .into_iter() @@ -30,7 +31,8 @@ pub fn setup_service(dirs: Vec) -> (PathBuf, FileSystemService) { }) .collect::>(); let service = FileSystemService::try_new(&allowed_dirs).unwrap(); - (temp_dir, service) + let allowed_dirs: Vec = allowed_dirs.iter().map(|i| i.into()).collect(); + (temp_dir, service, Arc::new(allowed_dirs)) } // Helper to create a temporary file diff --git a/tests/test_cli.rs b/tests/test_cli.rs index 0c950a5..ab521ed 100644 --- a/tests/test_cli.rs +++ b/tests/test_cli.rs @@ -38,11 +38,15 @@ fn test_parse_with_write_flag_long() { #[test] fn test_missing_required_directories() { let args = ["mcp-server"]; + + // parse should pass let result = parse_args(&args); - assert!(result.is_err()); - if let Err(e) = result { - assert_eq!(e.kind(), clap::error::ErrorKind::MissingRequiredArgument); - } + assert!(result.is_ok()); + + let result = result.unwrap().validate(); + assert!( + matches!(result, Err(message) if message.contains("is required when `--enable-roots` is not provided")) + ); } #[test] diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 456ecac..1a82d42 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -24,15 +24,15 @@ use tokio_util::compat::TokioAsyncReadCompatExt; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; -#[test] -fn test_try_new_success() { +#[tokio::test] +async fn test_try_new_success() { let temp_dir = get_temp_dir(); let dir_path = temp_dir.to_str().unwrap().to_string(); let result = FileSystemService::try_new(&[dir_path]); assert!(result.is_ok()); let service = result.unwrap(); - assert_eq!(service.allowed_directories().as_ref(), vec![temp_dir]); + assert_eq!(*service.allowed_directories().await, vec![temp_dir]); } #[test] @@ -41,29 +41,29 @@ fn test_try_new_invalid_directory() { let _ = FileSystemService::try_new(&["/does/not/exist".to_string()]); } -#[test] -fn test_allowed_directories() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); - let allowed = service.allowed_directories(); +#[tokio::test] +async fn test_allowed_directories() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); + let allowed = service.allowed_directories().await; assert_eq!(allowed.len(), 1); assert_eq!(allowed[0], temp_dir.join("dir1")); } #[tokio::test] async fn test_validate_path_allowed() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = temp_dir.join("dir1").join("test.txt"); create_temp_file(temp_dir.join("dir1").as_path(), "test.txt", "content"); - let result = service.validate_path(&file_path); + let result = service.validate_path(&file_path, allowed_dirs); assert!(result.is_ok()); assert_eq!(result.unwrap(), file_path); } #[tokio::test] async fn test_validate_path_denied() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, allowed_dirs) = setup_service(vec!["dir1".to_string()]); let outside_path = temp_dir.join("dir2").join("test.txt"); - let result = service.validate_path(&outside_path); + let result = service.validate_path(&outside_path, allowed_dirs); assert!(matches!(result, Err(ServiceError::FromString(_)))); } @@ -98,7 +98,7 @@ fn test_contains_symlink_with_symlink() { #[tokio::test] async fn test_get_file_stats() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "test.txt", "content"); let result = service.get_file_stats(&file_path).await.unwrap(); assert_eq!(result.size, 7); // "content" is 7 bytes @@ -111,7 +111,7 @@ async fn test_get_file_stats() { #[tokio::test] async fn test_zip_directory() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let dir_path = temp_dir.join("dir1"); create_temp_file(&dir_path, "file1.txt", "content1"); @@ -132,7 +132,7 @@ async fn test_zip_directory() { #[tokio::test] async fn test_zip_directory_already_exists() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let dir_path = temp_dir.join("dir1"); let zip_path = create_temp_file(&dir_path, "output.zip", "dummy"); let result = service @@ -150,7 +150,7 @@ async fn test_zip_directory_already_exists() { #[tokio::test] async fn test_zip_files() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let dir_path = temp_dir.join("dir1"); let file1 = create_temp_file(dir_path.as_path(), "file1.txt", "content1"); @@ -173,7 +173,7 @@ async fn test_zip_files() { #[tokio::test] async fn test_zip_files_empty_input() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let zip_path = temp_dir.join("output.zip"); let result = service .zip_files(vec![], zip_path.to_str().unwrap().to_string()) @@ -186,7 +186,7 @@ async fn test_zip_files_empty_input() { #[tokio::test] async fn test_unzip_file() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let dir_path = temp_dir.join("dir1"); let file1 = create_temp_file(&dir_path, "file1.txt", "content1"); let zip_path = dir_path.join("output.zip"); @@ -208,7 +208,7 @@ async fn test_unzip_file() { #[tokio::test] async fn test_unzip_file_non_existent() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let temp_dir = temp_dir.join("dir1"); let zip_path = temp_dir.join("non_existent.zip"); let extract_dir = temp_dir.join("extracted"); @@ -224,7 +224,7 @@ async fn test_unzip_file_non_existent() { #[tokio::test] async fn test_read_file() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "test.txt", "content"); let content = service.read_file(&file_path).await.unwrap(); assert_eq!(content, "content"); @@ -232,7 +232,7 @@ async fn test_read_file() { #[tokio::test] async fn test_create_directory() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let new_dir = temp_dir.join("dir1").join("new_dir"); let result = service.create_directory(&new_dir).await; @@ -242,7 +242,7 @@ async fn test_create_directory() { #[tokio::test] async fn test_move_file() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let src_path = create_temp_file(temp_dir.join("dir1").as_path(), "src.txt", "content"); let dest_path = temp_dir.join("dir1").join("dest.txt"); let result = service.move_file(&src_path, &dest_path).await; @@ -253,7 +253,7 @@ async fn test_move_file() { #[tokio::test] async fn test_list_directory() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let dir_path = temp_dir.join("dir1"); create_temp_file(&dir_path, "file1.txt", "content1"); create_temp_file(&dir_path, "file2.txt", "content2"); @@ -269,7 +269,7 @@ async fn test_list_directory() { #[tokio::test] async fn test_write_file() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = temp_dir.join("dir1").join("test.txt"); let content = "new content".to_string(); let result = service.write_file(&file_path, &content).await; @@ -277,14 +277,15 @@ async fn test_write_file() { assert_eq!(tokio_fs::read_to_string(&file_path).await.unwrap(), content); } -#[test] -fn test_search_files() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); +#[tokio::test] +async fn test_search_files() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let dir_path = temp_dir.join("dir1"); create_temp_file(&dir_path, "test1.txt", "content"); create_temp_file(&dir_path, "test2.doc", "content"); let result = service .search_files(&dir_path, "*.txt".to_string(), vec![]) + .await .unwrap(); let names: Vec<_> = result .into_iter() @@ -293,9 +294,9 @@ fn test_search_files() { assert_eq!(names, vec!["test1.txt"]); } -#[test] -fn test_search_files_with_exclude() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); +#[tokio::test] +async fn test_search_files_with_exclude() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let dir_path = temp_dir.join("dir1"); create_temp_file(&dir_path, "test1.txt", "content"); create_temp_file(&dir_path, "test2.txt", "content"); @@ -305,6 +306,7 @@ fn test_search_files_with_exclude() { "*.txt".to_string(), vec!["test2.txt".to_string()], ) + .await .unwrap(); let names: Vec<_> = result .into_iter() @@ -315,7 +317,7 @@ fn test_search_files_with_exclude() { #[test] fn test_create_unified_diff() { - let (_, service) = setup_service(vec![]); + let (_, service, _) = setup_service(vec![]); let original = "line1\nline2\nline3".to_string(); let new = "line1\nline4\nline3".to_string(); let diff = service.create_unified_diff(&original, &new, Some("test.txt".to_string())); @@ -328,7 +330,7 @@ fn test_create_unified_diff() { #[tokio::test] async fn test_apply_file_edits() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file( temp_dir.join("dir1").as_path(), "test.txt", @@ -351,7 +353,7 @@ async fn test_apply_file_edits() { #[tokio::test] async fn test_apply_file_edits_dry_run() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file( temp_dir.join("dir1").as_path(), "test.txt", @@ -374,7 +376,7 @@ async fn test_apply_file_edits_dry_run() { #[tokio::test] async fn test_apply_file_edits_no_match() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file( temp_dir.join("dir1").as_path(), "test.txt", @@ -592,7 +594,7 @@ fn test_display_format_for_empty_timestamps() { #[tokio::test] async fn test_apply_file_edits_mixed_indentation() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file( temp_dir.join("dir1").as_path(), "test_indent.txt", @@ -640,7 +642,7 @@ async fn test_apply_file_edits_mixed_indentation() { #[tokio::test] async fn test_apply_file_edits_mixed_indentation_2() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file( temp_dir.join("dir1").as_path(), "test_indent.txt", @@ -687,7 +689,7 @@ async fn test_apply_file_edits_mixed_indentation_2() { #[tokio::test] async fn test_exact_match() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir1"), @@ -712,7 +714,7 @@ async fn test_exact_match() { #[tokio::test] async fn test_exact_match_edit2() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir1"), "test_file1.txt", @@ -735,7 +737,7 @@ async fn test_exact_match_edit2() { #[tokio::test] async fn test_line_by_line_match_with_indent() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir1"), "test_file2.rs", @@ -760,7 +762,7 @@ async fn test_line_by_line_match_with_indent() { #[tokio::test] async fn test_dry_run_mode() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir1"), "test_file4.sh", @@ -783,7 +785,7 @@ async fn test_dry_run_mode() { #[tokio::test] async fn test_save_to_different_path() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let orig_file = create_temp_file( &temp_dir.as_path().join("dir1"), "test_file5.txt", @@ -811,7 +813,7 @@ async fn test_save_to_different_path() { #[tokio::test] async fn test_diff_backtick_formatting() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir1"), "test_file6.md", @@ -835,7 +837,7 @@ async fn test_diff_backtick_formatting() { #[tokio::test] async fn test_no_edits_provided() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir1"), "test_file7.toml", @@ -853,7 +855,7 @@ async fn test_no_edits_provided() { #[tokio::test] async fn test_preserve_windows_line_endings() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir1"), "test_file.txt", @@ -876,7 +878,7 @@ async fn test_preserve_windows_line_endings() { #[tokio::test] async fn test_preserve_unix_line_endings() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir1"), "unix_line_file.txt", @@ -901,7 +903,7 @@ async fn test_preserve_unix_line_endings() { #[tokio::test] // Issue #19: https://github.com/rust-mcp-stack/rust-mcp-filesystem/issues/19 async fn test_panic_on_out_of_bounds_edit() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); // Set up an edit that expects to match 5 lines let edit = EditOperation { @@ -927,7 +929,7 @@ async fn test_panic_on_out_of_bounds_edit() { #[tokio::test] async fn test_content_search() { - let (temp_dir, service) = setup_service(vec!["dir_search".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir_search".to_string()]); let file = create_temp_file( &temp_dir.as_path().join("dir_search"), "file_to_search.txt", @@ -974,7 +976,7 @@ async fn test_content_search() { #[test] fn test_match_near_start_short_line() { - let (_, service) = setup_service(vec!["dir_search".to_string()]); + let (_, service, _) = setup_service(vec!["dir_search".to_string()]); let line = "match this text"; let m = Match::new(0, 5); @@ -987,7 +989,7 @@ fn test_match_near_start_short_line() { #[tokio::test] async fn test_snippet_back_chars() { - let (_, service) = setup_service(vec!["dir_search".to_string()]); + let (_, service, _) = setup_service(vec!["dir_search".to_string()]); let line = "this is a long enough line for testing match in middle"; let m = Match::new(40, 45); let result = service.extract_snippet(line, m, Some(20), Some(5)); @@ -1007,7 +1009,7 @@ async fn test_snippet_back_chars() { #[test] fn test_match_triggers_only_end_ellipsis() { - let (_, service) = setup_service(vec!["dir_search".to_string()]); + let (_, service, _) = setup_service(vec!["dir_search".to_string()]); let line = "match is at start but line is long"; let m = Match::new(0, 5); @@ -1021,7 +1023,7 @@ fn test_match_triggers_only_end_ellipsis() { #[test] fn test_match_triggers_only_start_ellipsis() { - let (_, service) = setup_service(vec!["dir_search".to_string()]); + let (_, service, _) = setup_service(vec!["dir_search".to_string()]); let line = "line is long and match is near end"; let m = Match::new(31, 36); @@ -1033,7 +1035,7 @@ fn test_match_triggers_only_start_ellipsis() { #[test] fn test_trim_applied() { - let (_, service) = setup_service(vec!["dir_search".to_string()]); + let (_, service, _) = setup_service(vec!["dir_search".to_string()]); let line = " match here with spaces "; let m = Match::new(5, 10); @@ -1047,7 +1049,7 @@ fn test_trim_applied() { #[test] fn test_exact_snippet_end() { - let (_, service) = setup_service(vec!["dir_search".to_string()]); + let (_, service, _allowed_dirs) = setup_service(vec!["dir_search".to_string()]); let line = "some content with match inside"; let m = Match::new(18, 23); let result = service.extract_snippet(line, m, Some(line.len()), Some(18)); @@ -1055,9 +1057,9 @@ fn test_exact_snippet_end() { assert_eq!(result, "some content with match inside"); } -#[test] -fn search_files_content() { - let (temp_dir, service) = setup_service(vec!["dir_search".to_string()]); +#[tokio::test] +async fn search_files_content() { + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir_search".to_string()]); create_temp_file( &temp_dir.as_path().join("dir_search"), @@ -1090,6 +1092,7 @@ fn search_files_content() { true, None, ) + .await .unwrap(); assert_eq!(results.len(), 2); assert_eq!(results[0].matches.len(), 2); @@ -1098,7 +1101,7 @@ fn search_files_content() { #[test] fn test_extract_snippet_bug_37() { - let (_, service) = setup_service(vec!["dir_search".to_string()]); + let (_, service, _) = setup_service(vec!["dir_search".to_string()]); // Input string : ’ starts spans 3 bytes: 0xE2 0x80 0x99. let line = "If and when that happens, however, we will not be able to declare victory quite yet. Defeating the open conspiracy to deprive students of physical access to books will do little to counteract the more diffuse confluence of forces that are depriving students of their education with a curly apostrophe ’ followed by more text"; diff --git a/tests/test_tools.rs b/tests/test_tools.rs index 8c46611..67d673e 100644 --- a/tests/test_tools.rs +++ b/tests/test_tools.rs @@ -8,7 +8,7 @@ use std::fs; #[tokio::test] async fn test_create_directory_new_directory() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let new_dir = temp_dir.join("dir1").join("new_dir"); let params = CreateDirectoryTool { path: new_dir.to_str().unwrap().to_string(), @@ -39,7 +39,7 @@ async fn test_create_directory_new_directory() { #[tokio::test] async fn test_create_directory_existing_directory() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let existing_dir = temp_dir.join("dir1").join("existing_dir"); fs::create_dir_all(&existing_dir).unwrap(); let params = CreateDirectoryTool { @@ -71,7 +71,7 @@ async fn test_create_directory_existing_directory() { #[tokio::test] async fn test_create_directory_nested() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let nested_dir = temp_dir.join("dir1").join("nested/subdir"); let params = CreateDirectoryTool { path: nested_dir.to_str().unwrap().to_string(), @@ -100,7 +100,7 @@ async fn test_create_directory_nested() { #[tokio::test] async fn test_create_directory_outside_allowed() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let outside_dir = temp_dir.join("dir2").join("forbidden"); let params = CreateDirectoryTool { path: outside_dir.to_str().unwrap().to_string(), @@ -115,7 +115,7 @@ async fn test_create_directory_outside_allowed() { #[tokio::test] async fn test_create_directory_invalid_path() { - let (temp_dir, service) = setup_service(vec!["dir1".to_string()]); + let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let invalid_path = temp_dir.join("dir1").join("invalid\0dir"); let params = CreateDirectoryTool { path: invalid_path From 5f7680ec21a51c39dee28a41863da70dff3db681 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 19 Sep 2025 16:17:39 -0300 Subject: [PATCH 06/12] update docs --- docs/guide/cli-command-options.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/guide/cli-command-options.md b/docs/guide/cli-command-options.md index 0a430a7..894483c 100644 --- a/docs/guide/cli-command-options.md +++ b/docs/guide/cli-command-options.md @@ -1,10 +1,10 @@ ## CLI Command Options ```sh -Usage: rust-mcp-filesystem [OPTIONS] ... +Usage: rust-mcp-filesystem [OPTIONS] [ALLOWED_DIRECTORIES]... Arguments: - ... + [ALLOWED_DIRECTORIES]... Provide a space-separated list of directories that are permitted for the operation. This list allows multiple directories to be provided. @@ -12,7 +12,12 @@ Arguments: Options: -w, --allow-write - Enables read/write mode for the app, allowing both reading and writing. + Enables read/write mode for the app, allowing both reading and writing. Defaults to disabled. + + -t, --enable-roots + Enables dynamic directory access control via Roots from the MCP client side. Defaults to disabled. + When enabled, MCP clients that support Roots can dynamically update the allowed directories. + Any directories provided by the client will completely replace the initially configured allowed directories on the server. -h, --help Print help (see a summary with '-h') From 7675244fa2d75ace7d7ca11c9572c9db81700809 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 19 Sep 2025 16:21:39 -0300 Subject: [PATCH 07/12] typo --- src/handler.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/handler.rs b/src/handler.rs index 8d102f5..c869376 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -5,13 +5,13 @@ use crate::cli::CommandArguments; use crate::error::ServiceError; use crate::{error::ServiceResult, fs_service::FileSystemService, tools::*}; use async_trait::async_trait; +use rust_mcp_sdk::McpServer; use rust_mcp_sdk::mcp_server::ServerHandler; use rust_mcp_sdk::schema::RootsListChangedNotification; use rust_mcp_sdk::schema::{ - schema_utils::CallToolError, CallToolRequest, CallToolResult, InitializeRequest, - InitializeResult, ListToolsRequest, ListToolsResult, RpcError, + CallToolRequest, CallToolResult, InitializeRequest, InitializeResult, ListToolsRequest, + ListToolsResult, RpcError, schema_utils::CallToolError, }; -use rust_mcp_sdk::McpServer; pub struct FileSystemHandler { readonly: bool, @@ -87,7 +87,7 @@ impl FileSystemHandler { } else { let fs_service = self.fs_service.clone(); let mcp_roots_support = self.mcp_roots_support; - // retreive roots from the client and update the allowed dirctories accordingly + // retrieve roots from the client and update the allowed directories accordingly tokio::spawn(async move { let roots = match runtime.clone().list_roots(None).await { Ok(roots_result) => roots_result.roots, From 9149a73007743bc89afedb2370bbe60a3f21a1f5 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 19 Sep 2025 16:38:34 -0300 Subject: [PATCH 08/12] clippy --- rust-toolchain.toml | 2 +- src/cli.rs | 9 +++++---- src/error.rs | 8 +++++--- src/fs_service.rs | 10 ++++------ src/fs_service/utils.rs | 2 +- src/handler.rs | 6 +++--- src/server.rs | 6 +++--- src/tools/create_directory.rs | 4 ++-- src/tools/directory_tree.rs | 6 +++--- src/tools/edit_file.rs | 4 ++-- src/tools/get_file_info.rs | 4 ++-- src/tools/list_allowed_directories.rs | 4 ++-- src/tools/list_directory.rs | 4 ++-- src/tools/list_directory_with_sizes.rs | 6 +++--- src/tools/move_file.rs | 4 ++-- src/tools/read_files.rs | 4 ++-- src/tools/read_multiple_files.rs | 4 ++-- src/tools/search_file.rs | 4 ++-- src/tools/search_files_content.rs | 4 ++-- src/tools/write_file.rs | 4 ++-- src/tools/zip_unzip.rs | 4 ++-- tests/common/common.rs | 2 +- tests/test_fs_service.rs | 2 +- tests/test_tools.rs | 2 +- 24 files changed, 55 insertions(+), 54 deletions(-) diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 908d2ec..7855e6d 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.89.0" +channel = "1.88.0" components = ["rustfmt", "clippy"] diff --git a/src/cli.rs b/src/cli.rs index 1917faf..61a3644 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use clap::{arg, command, Parser}; +use clap::{Parser, arg, command}; #[derive(Parser, Debug)] #[command(name = env!("CARGO_PKG_NAME"))] @@ -30,9 +30,10 @@ pub struct CommandArguments { impl CommandArguments { pub fn validate(&self) -> Result<(), String> { if !self.enable_roots && self.allowed_directories.is_empty() { - return Err( - format!(" is required when `--enable-roots` is not provided.\n Run `{} --help` to view the usage instructions.",env!("CARGO_PKG_NAME")) - ); + return Err(format!( + " is required when `--enable-roots` is not provided.\n Run `{} --help` to view the usage instructions.", + env!("CARGO_PKG_NAME") + )); } Ok(()) } diff --git a/src/error.rs b/src/error.rs index 07ee74e..2675360 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ use async_zip::error::ZipError; use glob::PatternError; -use rust_mcp_sdk::schema::{schema_utils::SdkError, RpcError}; -use rust_mcp_sdk::{error::McpSdkError, TransportError}; +use rust_mcp_sdk::schema::{RpcError, schema_utils::SdkError}; +use rust_mcp_sdk::{TransportError, error::McpSdkError}; use thiserror::Error; use tokio::io; @@ -10,7 +10,9 @@ pub type ServiceResult = core::result::Result; #[derive(Debug, Error)] pub enum ServiceError { - #[error("Service is running in read-only mode. To enable write access, please run with the --allow-write flag.")] + #[error( + "Service is running in read-only mode. To enable write access, please run with the --allow-write flag." + )] NoWriteAccess, #[error("{0}")] FromString(String), diff --git a/src/fs_service.rs b/src/fs_service.rs index 310414a..ef32c94 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -10,10 +10,10 @@ use glob::Pattern; use grep::{ matcher::{Match, Matcher}, regex::RegexMatcherBuilder, - searcher::{sinks::UTF8, BinaryDetection, Searcher}, + searcher::{BinaryDetection, Searcher, sinks::UTF8}, }; use rust_mcp_sdk::schema::RpcError; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use similar::TextDiff; use std::{ collections::HashSet, @@ -568,14 +568,12 @@ impl FileSystemService { if root_path == entry.path() { return false; } - - let is_match = glob_pattern + glob_pattern .as_ref() .map(|glob| { glob.matches(&entry.file_name().to_str().unwrap_or("").to_lowercase()) }) - .unwrap_or(false); - is_match + .unwrap_or(false) }); Ok(result) diff --git a/src/fs_service/utils.rs b/src/fs_service/utils.rs index 189c69d..05c7ae2 100644 --- a/src/fs_service/utils.rs +++ b/src/fs_service/utils.rs @@ -4,7 +4,7 @@ use std::{ time::SystemTime, }; -use async_zip::{error::ZipError, tokio::write::ZipFileWriter, Compression, ZipEntryBuilder}; +use async_zip::{Compression, ZipEntryBuilder, error::ZipError, tokio::write::ZipFileWriter}; use chrono::{DateTime, Local}; use dirs::home_dir; diff --git a/src/handler.rs b/src/handler.rs index c869376..822e81e 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -100,7 +100,8 @@ impl FileSystemHandler { vec![] } else { let roots: Vec<_> = roots.iter().map(|v| v.uri.as_str()).collect(); - let valid_roots = match fs_service.valid_roots(roots) { + + match fs_service.valid_roots(roots) { Ok((roots, skipped)) => { if let Some(message) = skipped { let _ = runtime.stderr_message(message.to_string()).await; @@ -108,8 +109,7 @@ impl FileSystemHandler { roots } Err(_err) => vec![], - }; - valid_roots + } }; if valid_roots.is_empty() && !mcp_roots_support { diff --git a/src/server.rs b/src/server.rs index 371c678..133e664 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,8 +1,8 @@ use rust_mcp_sdk::schema::{ - Implementation, InitializeResult, ServerCapabilities, ServerCapabilitiesTools, - LATEST_PROTOCOL_VERSION, + Implementation, InitializeResult, LATEST_PROTOCOL_VERSION, ServerCapabilities, + ServerCapabilitiesTools, }; -use rust_mcp_sdk::{mcp_server::server_runtime, McpServer, StdioTransport, TransportOptions}; +use rust_mcp_sdk::{McpServer, StdioTransport, TransportOptions, mcp_server::server_runtime}; use crate::handler::FileSystemHandler; use crate::{cli::CommandArguments, error::ServiceResult}; diff --git a/src/tools/create_directory.rs b/src/tools/create_directory.rs index 48f01ff..36dcbbc 100644 --- a/src/tools/create_directory.rs +++ b/src/tools/create_directory.rs @@ -1,8 +1,8 @@ use std::path::Path; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/src/tools/directory_tree.rs b/src/tools/directory_tree.rs index b9d876b..a347ea2 100644 --- a/src/tools/directory_tree.rs +++ b/src/tools/directory_tree.rs @@ -1,7 +1,7 @@ -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; -use serde_json::{json, Map, Value}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; +use serde_json::{Map, Value, json}; use crate::error::ServiceError; use crate::fs_service::FileSystemService; diff --git a/src/tools/edit_file.rs b/src/tools/edit_file.rs index a29311c..0d63fbe 100644 --- a/src/tools/edit_file.rs +++ b/src/tools/edit_file.rs @@ -1,8 +1,8 @@ use std::path::Path; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/src/tools/get_file_info.rs b/src/tools/get_file_info.rs index 55c3a03..4d5d42b 100644 --- a/src/tools/get_file_info.rs +++ b/src/tools/get_file_info.rs @@ -1,8 +1,8 @@ use std::path::Path; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/src/tools/list_allowed_directories.rs b/src/tools/list_allowed_directories.rs index 84a9887..bf401ea 100644 --- a/src/tools/list_allowed_directories.rs +++ b/src/tools/list_allowed_directories.rs @@ -1,6 +1,6 @@ -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/src/tools/list_directory.rs b/src/tools/list_directory.rs index cb0b95e..2e7afbb 100644 --- a/src/tools/list_directory.rs +++ b/src/tools/list_directory.rs @@ -1,8 +1,8 @@ use std::path::Path; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/src/tools/list_directory_with_sizes.rs b/src/tools/list_directory_with_sizes.rs index 8929cf9..803d290 100644 --- a/src/tools/list_directory_with_sizes.rs +++ b/src/tools/list_directory_with_sizes.rs @@ -1,11 +1,11 @@ -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use std::fmt::Write; use std::path::Path; -use crate::fs_service::utils::format_bytes; use crate::fs_service::FileSystemService; +use crate::fs_service::utils::format_bytes; #[mcp_tool( name = "list_directory_with_sizes", diff --git a/src/tools/move_file.rs b/src/tools/move_file.rs index adafaa7..3ea5096 100644 --- a/src/tools/move_file.rs +++ b/src/tools/move_file.rs @@ -1,8 +1,8 @@ use std::path::Path; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/src/tools/read_files.rs b/src/tools/read_files.rs index e4bc34a..a6c91d1 100644 --- a/src/tools/read_files.rs +++ b/src/tools/read_files.rs @@ -1,8 +1,8 @@ use std::path::Path; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/src/tools/read_multiple_files.rs b/src/tools/read_multiple_files.rs index 6df881f..fc903c2 100644 --- a/src/tools/read_multiple_files.rs +++ b/src/tools/read_multiple_files.rs @@ -1,9 +1,9 @@ use std::path::Path; use futures::future::join_all; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/src/tools/search_file.rs b/src/tools/search_file.rs index 627b4f8..510ae99 100644 --- a/src/tools/search_file.rs +++ b/src/tools/search_file.rs @@ -1,8 +1,8 @@ use std::path::Path; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; #[mcp_tool( diff --git a/src/tools/search_files_content.rs b/src/tools/search_files_content.rs index c612a23..f5cf9cf 100644 --- a/src/tools/search_files_content.rs +++ b/src/tools/search_files_content.rs @@ -1,8 +1,8 @@ use crate::error::ServiceError; use crate::fs_service::{FileSearchResult, FileSystemService}; -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use std::fmt::Write; #[mcp_tool( name = "search_files_content", diff --git a/src/tools/write_file.rs b/src/tools/write_file.rs index f323950..52797cb 100644 --- a/src/tools/write_file.rs +++ b/src/tools/write_file.rs @@ -1,10 +1,10 @@ use rust_mcp_sdk::{ - macros::{mcp_tool, JsonSchema}, + macros::{JsonSchema, mcp_tool}, schema::TextContent, }; use std::path::Path; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; #[mcp_tool( diff --git a/src/tools/zip_unzip.rs b/src/tools/zip_unzip.rs index 80a3e7c..88f77a1 100644 --- a/src/tools/zip_unzip.rs +++ b/src/tools/zip_unzip.rs @@ -1,6 +1,6 @@ -use rust_mcp_sdk::macros::{mcp_tool, JsonSchema}; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, CallToolResult}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; diff --git a/tests/common/common.rs b/tests/common/common.rs index 468d790..8650026 100644 --- a/tests/common/common.rs +++ b/tests/common/common.rs @@ -8,7 +8,7 @@ use std::{ use clap::Parser; use rust_mcp_filesystem::{ cli::CommandArguments, - fs_service::{file_info::FileInfo, FileSystemService}, + fs_service::{FileSystemService, file_info::FileInfo}, }; use tempfile::TempDir; diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 1a82d42..0262115 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -10,9 +10,9 @@ use common::setup_service; use dirs::home_dir; use grep::matcher::Match; use rust_mcp_filesystem::error::ServiceError; +use rust_mcp_filesystem::fs_service::FileSystemService; use rust_mcp_filesystem::fs_service::file_info::FileInfo; use rust_mcp_filesystem::fs_service::utils::*; -use rust_mcp_filesystem::fs_service::FileSystemService; use rust_mcp_filesystem::tools::EditOperation; use std::fs::{self, File}; use std::io::Write; diff --git a/tests/test_tools.rs b/tests/test_tools.rs index 67d673e..542fff1 100644 --- a/tests/test_tools.rs +++ b/tests/test_tools.rs @@ -3,7 +3,7 @@ pub mod common; use common::setup_service; use rust_mcp_filesystem::tools::*; -use rust_mcp_sdk::schema::{schema_utils::CallToolError, ContentBlock}; +use rust_mcp_sdk::schema::{ContentBlock, schema_utils::CallToolError}; use std::fs; #[tokio::test] From 7540e62c67f0b3bb650b30d2b95d77f31a47dbbf Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 19 Sep 2025 17:36:00 -0300 Subject: [PATCH 09/12] add read_media tool --- Cargo.lock | 34 +++++++ Cargo.toml | 2 + src/error.rs | 6 ++ src/fs_service.rs | 91 ++++++++++++++++++- src/handler.rs | 81 +++++++++-------- src/tools.rs | 22 +++-- src/tools/read_media_file.rs | 61 +++++++++++++ ...e_files.rs => read_multiple_text_files.rs} | 18 ++-- .../{read_files.rs => read_text_file.rs} | 12 +-- tests/test_fs_service.rs | 2 +- tests/test_tools.rs | 3 + 11 files changed, 263 insertions(+), 69 deletions(-) create mode 100644 src/tools/read_media_file.rs rename src/tools/{read_multiple_files.rs => read_multiple_text_files.rs} (84%) rename src/tools/{read_files.rs => read_text_file.rs} (80%) diff --git a/Cargo.lock b/Cargo.lock index a177319..4b66f9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,12 @@ version = "3.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.10.1" @@ -202,6 +208,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.3" @@ -380,6 +397,12 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "futures" version = "0.3.31" @@ -639,6 +662,15 @@ dependencies = [ "cc", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "io-uring" version = "0.7.10" @@ -951,12 +983,14 @@ version = "0.2.3" dependencies = [ "async-trait", "async_zip", + "base64", "chrono", "clap", "dirs", "futures", "glob", "grep", + "infer", "rust-mcp-sdk", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 40043de..5d1e44b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,8 @@ futures = "0.3" tokio-util = "0.7" async_zip = { version = "0.0", features = ["full"] } grep = "0.3" +base64 = "0.22" +infer = "0.19.0" [dev-dependencies] tempfile = "3.2" diff --git a/src/error.rs b/src/error.rs index 2675360..d4977e0 100644 --- a/src/error.rs +++ b/src/error.rs @@ -34,4 +34,10 @@ pub enum ServiceError { ZipError(#[from] ZipError), #[error("{0}")] GlobPatternError(#[from] PatternError), + #[error("File size exceeds the maximum allowed limit of {0} bytes")] + FileTooLarge(usize), + #[error("File size is below the minimum required limit of {0} bytes")] + FileTooSmall(usize), + #[error("The file is either not an image/audio type or is unsupported (mime:{0}).")] + InvalidMediaFile(String), } diff --git a/src/fs_service.rs b/src/fs_service.rs index ef32c94..29114bc 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -5,6 +5,7 @@ use crate::{ tools::EditOperation, }; use async_zip::tokio::{read::seek::ZipFileReader, write::ZipFileWriter}; +use base64::{engine::general_purpose, write::EncoderWriter}; use file_info::FileInfo; use glob::Pattern; use grep::{ @@ -19,12 +20,13 @@ use std::{ collections::HashSet, env, fs::{self}, + io::Write, path::{Path, PathBuf}, sync::Arc, }; use tokio::{ - fs::File, - io::{AsyncWriteExt, BufReader}, + fs::{File, metadata}, + io::{AsyncReadExt, AsyncWriteExt, BufReader}, sync::RwLock, }; use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt}; @@ -432,7 +434,90 @@ impl FileSystemService { Ok(result_message) } - pub async fn read_file(&self, file_path: &Path) -> ServiceResult { + pub fn mime_from_path(&self, path: &Path) -> ServiceResult { + let is_svg = path + .extension() + .is_some_and(|e| e.to_str().is_some_and(|s| s == "svg")); + // consider it is a svg file as we cannot detect svg from bytes pattern + if is_svg { + return Ok(infer::Type::new( + infer::MatcherType::Image, + "image/svg+xml", + "svg", + |_: &[u8]| true, + )); + + // infer::Type::new(infer::MatcherType::Image, "", "svg",); + } + let kind = infer::get_from_path(path)?.ok_or(ServiceError::FromString( + "File tyle is unknown!".to_string(), + ))?; + Ok(kind) + } + + pub async fn validate_file_size>( + &self, + path: P, + min_bytes: Option, + max_bytes: Option, + ) -> ServiceResult<()> { + if min_bytes.is_none() && max_bytes.is_none() { + return Ok(()); + } + + let file_size = metadata(&path).await?.len() as usize; + + match (min_bytes, max_bytes) { + (_, Some(max)) if file_size > max => Err(ServiceError::FileTooLarge(max)), + (Some(min), _) if file_size < min => Err(ServiceError::FileTooSmall(min)), + _ => Ok(()), + } + } + + pub async fn read_media_file( + &self, + file_path: &Path, + max_bytes: Option, + ) -> ServiceResult<(infer::Type, String)> { + let allowed_directories = self.allowed_directories().await; + let valid_path = self.validate_path(file_path, allowed_directories)?; + self.validate_file_size(&valid_path, None, max_bytes) + .await?; + let kind = self.mime_from_path(&valid_path)?; + let content = self.read_file_as_base64(&valid_path).await?; + Ok((kind, content)) + } + + // reads file as base64 efficiently in a streaming manner + async fn read_file_as_base64(&self, file_path: &Path) -> ServiceResult { + let file = File::open(file_path).await?; + let mut reader = BufReader::new(file); + + let mut output = Vec::new(); + { + // Wrap output Vec in a Base64 encoder writer + let mut encoder = EncoderWriter::new(&mut output, &general_purpose::STANDARD); + + let mut buffer = [0u8; 8192]; + loop { + let n = reader.read(&mut buffer).await?; + if n == 0 { + break; + } + // Write raw bytes to the Base64 encoder + encoder.write_all(&buffer[..n])?; + } + // Make sure to flush any remaining bytes + encoder.flush()?; + } // drop encoder before consuming output + + // Convert the Base64 bytes to String (safe UTF-8) + let base64_string = + String::from_utf8(output).map_err(|err| ServiceError::FromString(format!("{err}")))?; + Ok(base64_string) + } + + pub async fn read_text_file(&self, file_path: &Path) -> ServiceResult { let allowed_directories = self.allowed_directories().await; let valid_path = self.validate_path(file_path, allowed_directories)?; let content = tokio::fs::read_to_string(valid_path).await?; diff --git a/src/handler.rs b/src/handler.rs index 822e81e..8052f92 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -87,48 +87,46 @@ impl FileSystemHandler { } else { let fs_service = self.fs_service.clone(); let mcp_roots_support = self.mcp_roots_support; - // retrieve roots from the client and update the allowed directories accordingly - tokio::spawn(async move { - let roots = match runtime.clone().list_roots(None).await { - Ok(roots_result) => roots_result.roots, - Err(_err) => { - vec![] - } - }; - - let valid_roots = if roots.is_empty() { + // retreive roots from the client and update the allowed dirctories accordingly + let roots = match runtime.clone().list_roots(None).await { + Ok(roots_result) => roots_result.roots, + Err(_err) => { vec![] - } else { - let roots: Vec<_> = roots.iter().map(|v| v.uri.as_str()).collect(); - - match fs_service.valid_roots(roots) { - Ok((roots, skipped)) => { - if let Some(message) = skipped { - let _ = runtime.stderr_message(message.to_string()).await; - } - roots + } + }; + + let valid_roots = if roots.is_empty() { + vec![] + } else { + let roots: Vec<_> = roots.iter().map(|v| v.uri.as_str()).collect(); + + match fs_service.valid_roots(roots) { + Ok((roots, skipped)) => { + if let Some(message) = skipped { + let _ = runtime.stderr_message(message.to_string()).await; } - Err(_err) => vec![], + roots } - }; + Err(_err) => vec![], + } + }; - if valid_roots.is_empty() && !mcp_roots_support { - let message = if allowed_directories.is_empty() { - "Server cannot operate: No allowed directories available. Server was started without command-line directories and client provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories." - } else { - "Client provided empty roots. Allowed directories passed from command-line will be used." - }; - let _ = runtime.stderr_message(message.to_string()).await; + if valid_roots.is_empty() && !mcp_roots_support { + let message = if allowed_directories.is_empty() { + "Server cannot operate: No allowed directories available. Server was started without command-line directories and client provided empty roots. Please either: 1) Start server with directory arguments, or 2) Use a client that supports MCP roots protocol and provides valid root directories." } else { - let num_valid_roots = valid_roots.len(); + "Client provided empty roots. Allowed directories passed from command-line will be used." + }; + let _ = runtime.stderr_message(message.to_string()).await; + } else { + let num_valid_roots = valid_roots.len(); - fs_service.update_allowed_paths(valid_roots).await; - let message = format!( - "Updated allowed directories from MCP roots: {num_valid_roots} valid directories", - ); - let _ = runtime.stderr_message(message.to_string()).await; - } - }); + fs_service.update_allowed_paths(valid_roots).await; + let message = format!( + "Updated allowed directories from MCP roots: {num_valid_roots} valid directories", + ); + let _ = runtime.stderr_message(message.to_string()).await; + } } } } @@ -196,11 +194,14 @@ impl ServerHandler for FileSystemHandler { } match tool_params { - FileSystemTools::ReadFileTool(params) => { - ReadFileTool::run_tool(params, &self.fs_service).await + FileSystemTools::ReadMediaFileTool(params) => { + ReadMediaFileTool::run_tool(params, &self.fs_service).await + } + FileSystemTools::ReadTextFileTool(params) => { + ReadTextFileTool::run_tool(params, &self.fs_service).await } - FileSystemTools::ReadMultipleFilesTool(params) => { - ReadMultipleFilesTool::run_tool(params, &self.fs_service).await + FileSystemTools::ReadMultipleTextFilesTool(params) => { + ReadMultipleTextFilesTool::run_tool(params, &self.fs_service).await } FileSystemTools::WriteFileTool(params) => { WriteFileTool::run_tool(params, &self.fs_service).await diff --git a/src/tools.rs b/src/tools.rs index fea0583..6ed6b1b 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -6,8 +6,9 @@ mod list_allowed_directories; mod list_directory; mod list_directory_with_sizes; mod move_file; -mod read_files; -mod read_multiple_files; +mod read_media_file; +mod read_multiple_text_files; +mod read_text_file; mod search_file; mod search_files_content; mod write_file; @@ -21,8 +22,9 @@ pub use list_allowed_directories::ListAllowedDirectoriesTool; pub use list_directory::ListDirectoryTool; pub use list_directory_with_sizes::ListDirectoryWithSizesTool; pub use move_file::MoveFileTool; -pub use read_files::ReadFileTool; -pub use read_multiple_files::ReadMultipleFilesTool; +pub use read_media_file::ReadMediaFileTool; +pub use read_multiple_text_files::ReadMultipleTextFilesTool; +pub use read_text_file::ReadTextFileTool; pub use rust_mcp_sdk::tool_box; pub use search_file::SearchFilesTool; pub use search_files_content::SearchFilesContentTool; @@ -33,7 +35,7 @@ pub use zip_unzip::{UnzipFileTool, ZipDirectoryTool, ZipFilesTool}; tool_box!( FileSystemTools, [ - ReadFileTool, + ReadTextFileTool, CreateDirectoryTool, DirectoryTreeTool, EditFileTool, @@ -41,14 +43,15 @@ tool_box!( ListAllowedDirectoriesTool, ListDirectoryTool, MoveFileTool, - ReadMultipleFilesTool, + ReadMultipleTextFilesTool, SearchFilesTool, WriteFileTool, ZipFilesTool, UnzipFileTool, ZipDirectoryTool, SearchFilesContentTool, - ListDirectoryWithSizesTool + ListDirectoryWithSizesTool, + ReadMediaFileTool ] ); @@ -64,14 +67,15 @@ impl FileSystemTools { | FileSystemTools::ZipFilesTool(_) | FileSystemTools::UnzipFileTool(_) | FileSystemTools::ZipDirectoryTool(_) => true, - FileSystemTools::ReadFileTool(_) + FileSystemTools::ReadTextFileTool(_) | FileSystemTools::DirectoryTreeTool(_) | FileSystemTools::GetFileInfoTool(_) | FileSystemTools::ListAllowedDirectoriesTool(_) | FileSystemTools::ListDirectoryTool(_) - | FileSystemTools::ReadMultipleFilesTool(_) + | FileSystemTools::ReadMultipleTextFilesTool(_) | FileSystemTools::SearchFilesContentTool(_) | FileSystemTools::ListDirectoryWithSizesTool(_) + | FileSystemTools::ReadMediaFileTool(_) | FileSystemTools::SearchFilesTool(_) => false, } } diff --git a/src/tools/read_media_file.rs b/src/tools/read_media_file.rs new file mode 100644 index 0000000..7c606d4 --- /dev/null +++ b/src/tools/read_media_file.rs @@ -0,0 +1,61 @@ +use std::path::Path; + +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; +use rust_mcp_sdk::schema::{AudioContent, ImageContent}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; + +use crate::error::ServiceError; +use crate::fs_service::FileSystemService; + +#[mcp_tool( + name = "read_media_file", + title="Read an Image or Audio file", + description = concat!("Reads an image or audio file and returns its Base64-encoded content along with the corresponding MIME type. ", + "The max_bytes argument could be used to enforce an upper limit on the size of a file to read ", + "if the media file exceeds this limit, the operation will return an error instead of reading the media file. ", + "Access is restricted to files within allowed directories only."), + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false, + read_only_hint = true +)] +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] +pub struct ReadMediaFileTool { + /// The path of the file to read. + pub path: String, + /// Maximum allowed file size (in bytes) to be read. + pub max_bytes: Option, +} + +impl ReadMediaFileTool { + pub async fn run_tool( + params: Self, + context: &FileSystemService, + ) -> std::result::Result { + let (kind, content) = context + .read_media_file( + Path::new(¶ms.path), + params.max_bytes.map(|v| v as usize), + ) + .await + .map_err(CallToolError::new)?; + let mime_type = kind.mime_type().to_string(); + let call_result = match kind.matcher_type() { + infer::MatcherType::Image => { + let image_content: ImageContent = ImageContent::new(content, mime_type, None, None); + CallToolResult::image_content(vec![image_content]) + } + infer::MatcherType::Audio => { + let audio_content: AudioContent = AudioContent::new(content, mime_type, None, None); + CallToolResult::audio_content(vec![audio_content]) + } + _ => { + return Err(CallToolError::from_message( + ServiceError::InvalidMediaFile(mime_type).to_string(), + )); + } + }; + + Ok(call_result) + } +} diff --git a/src/tools/read_multiple_files.rs b/src/tools/read_multiple_text_files.rs similarity index 84% rename from src/tools/read_multiple_files.rs rename to src/tools/read_multiple_text_files.rs index fc903c2..640a101 100644 --- a/src/tools/read_multiple_files.rs +++ b/src/tools/read_multiple_text_files.rs @@ -1,16 +1,14 @@ -use std::path::Path; - +use crate::fs_service::FileSystemService; use futures::future::join_all; use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; use rust_mcp_sdk::schema::TextContent; use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; - -use crate::fs_service::FileSystemService; +use std::path::Path; #[mcp_tool( - name = "read_multiple_files", - title="Read Multiple Files", - description = concat!("Read the contents of multiple files simultaneously. ", + name = "read_multiple_text_files", + title="Read Multiple Text Files", + description = concat!("Read the contents of multiple text files simultaneously as text. ", "This is more efficient than reading files one by one when you need to analyze ", "or compare multiple files. Each file's content is returned with its ", "path as a reference. Failed reads for individual files won't stop ", @@ -21,12 +19,12 @@ use crate::fs_service::FileSystemService; read_only_hint = true )] #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] -pub struct ReadMultipleFilesTool { +pub struct ReadMultipleTextFilesTool { /// The list of file paths to read. pub paths: Vec, } -impl ReadMultipleFilesTool { +impl ReadMultipleTextFilesTool { pub async fn run_tool( params: Self, context: &FileSystemService, @@ -37,7 +35,7 @@ impl ReadMultipleFilesTool { .map(|path| async move { { let content = context - .read_file(Path::new(&path)) + .read_text_file(Path::new(&path)) .await .map_err(CallToolError::new); diff --git a/src/tools/read_files.rs b/src/tools/read_text_file.rs similarity index 80% rename from src/tools/read_files.rs rename to src/tools/read_text_file.rs index a6c91d1..06d6acf 100644 --- a/src/tools/read_files.rs +++ b/src/tools/read_text_file.rs @@ -7,9 +7,9 @@ use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; use crate::fs_service::FileSystemService; #[mcp_tool( - name = "read_file", - title="Read File", - description = concat!("Read the complete contents of a file from the file system. ", + name = "read_text_file", + title="Read a text file", + description = concat!("Read the complete contents of a text file from the file system as text. ", "Handles various text encodings and provides detailed error messages if the ", "file cannot be read. Use this tool when you need to examine the contents of ", "a single file. Only works within allowed directories."), @@ -19,18 +19,18 @@ use crate::fs_service::FileSystemService; read_only_hint = true )] #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] -pub struct ReadFileTool { +pub struct ReadTextFileTool { /// The path of the file to read. pub path: String, } -impl ReadFileTool { +impl ReadTextFileTool { pub async fn run_tool( params: Self, context: &FileSystemService, ) -> std::result::Result { let content = context - .read_file(Path::new(¶ms.path)) + .read_text_file(Path::new(¶ms.path)) .await .map_err(CallToolError::new)?; diff --git a/tests/test_fs_service.rs b/tests/test_fs_service.rs index 0262115..eef6b26 100644 --- a/tests/test_fs_service.rs +++ b/tests/test_fs_service.rs @@ -226,7 +226,7 @@ async fn test_unzip_file_non_existent() { async fn test_read_file() { let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]); let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "test.txt", "content"); - let content = service.read_file(&file_path).await.unwrap(); + let content = service.read_text_file(&file_path).await.unwrap(); assert_eq!(content, "content"); } diff --git a/tests/test_tools.rs b/tests/test_tools.rs index 542fff1..b79b519 100644 --- a/tests/test_tools.rs +++ b/tests/test_tools.rs @@ -128,3 +128,6 @@ async fn test_create_directory_invalid_path() { let err = result.unwrap_err(); assert!(matches!(err, CallToolError { .. })); } + +#[tokio::test] +async fn adhoc() {} From 38e3e9d838d67ffdbfe1e0bc15856ccea12771af Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 19 Sep 2025 18:57:08 -0300 Subject: [PATCH 10/12] add read multiple media files --- src/fs_service.rs | 20 +++++++++ src/handler.rs | 5 ++- src/tools.rs | 6 ++- src/tools/read_multiple_media_files.rs | 61 ++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 src/tools/read_multiple_media_files.rs diff --git a/src/fs_service.rs b/src/fs_service.rs index 29114bc..39ecb11 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -7,6 +7,7 @@ use crate::{ use async_zip::tokio::{read::seek::ZipFileReader, write::ZipFileWriter}; use base64::{engine::general_purpose, write::EncoderWriter}; use file_info::FileInfo; +use futures::{StreamExt, stream}; use glob::Pattern; use grep::{ matcher::{Match, Matcher}, @@ -38,6 +39,7 @@ use walkdir::WalkDir; const SNIPPET_MAX_LENGTH: usize = 200; const SNIPPET_BACKWARD_CHARS: usize = 30; +const MAX_CONCURRENT_FILE_READ: usize = 5; type PathResultList = Vec>; @@ -474,6 +476,24 @@ impl FileSystemService { } } + pub async fn read_media_files( + &self, + paths: Vec, + max_bytes: Option, + ) -> ServiceResult> { + let results = stream::iter(paths) + .map(|path| async { + self.read_media_file(Path::new(&path), max_bytes) + .await + .map_err(|e| (path, e)) + }) + .buffer_unordered(MAX_CONCURRENT_FILE_READ) // Process up to MAX_CONCURRENT_FILE_READ files concurrently + .filter_map(|result| async move { result.ok() }) + .collect::>() + .await; + Ok(results) + } + pub async fn read_media_file( &self, file_path: &Path, diff --git a/src/handler.rs b/src/handler.rs index 8052f92..9afc36f 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -99,7 +99,7 @@ impl FileSystemHandler { vec![] } else { let roots: Vec<_> = roots.iter().map(|v| v.uri.as_str()).collect(); - + match fs_service.valid_roots(roots) { Ok((roots, skipped)) => { if let Some(message) = skipped { @@ -197,6 +197,9 @@ impl ServerHandler for FileSystemHandler { FileSystemTools::ReadMediaFileTool(params) => { ReadMediaFileTool::run_tool(params, &self.fs_service).await } + FileSystemTools::ReadMultipleMediaFilesTool(params) => { + ReadMultipleMediaFilesTool::run_tool(params, &self.fs_service).await + } FileSystemTools::ReadTextFileTool(params) => { ReadTextFileTool::run_tool(params, &self.fs_service).await } diff --git a/src/tools.rs b/src/tools.rs index 6ed6b1b..3733b2a 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -7,6 +7,7 @@ mod list_directory; mod list_directory_with_sizes; mod move_file; mod read_media_file; +mod read_multiple_media_files; mod read_multiple_text_files; mod read_text_file; mod search_file; @@ -23,6 +24,7 @@ pub use list_directory::ListDirectoryTool; pub use list_directory_with_sizes::ListDirectoryWithSizesTool; pub use move_file::MoveFileTool; pub use read_media_file::ReadMediaFileTool; +pub use read_multiple_media_files::ReadMultipleMediaFilesTool; pub use read_multiple_text_files::ReadMultipleTextFilesTool; pub use read_text_file::ReadTextFileTool; pub use rust_mcp_sdk::tool_box; @@ -51,7 +53,8 @@ tool_box!( ZipDirectoryTool, SearchFilesContentTool, ListDirectoryWithSizesTool, - ReadMediaFileTool + ReadMediaFileTool, + ReadMultipleMediaFilesTool ] ); @@ -76,6 +79,7 @@ impl FileSystemTools { | FileSystemTools::SearchFilesContentTool(_) | FileSystemTools::ListDirectoryWithSizesTool(_) | FileSystemTools::ReadMediaFileTool(_) + | FileSystemTools::ReadMultipleMediaFilesTool(_) | FileSystemTools::SearchFilesTool(_) => false, } } diff --git a/src/tools/read_multiple_media_files.rs b/src/tools/read_multiple_media_files.rs new file mode 100644 index 0000000..00f5c79 --- /dev/null +++ b/src/tools/read_multiple_media_files.rs @@ -0,0 +1,61 @@ +use crate::fs_service::FileSystemService; +use rust_mcp_sdk::macros::{JsonSchema, mcp_tool}; +use rust_mcp_sdk::schema::{AudioContent, ContentBlock, ImageContent}; +use rust_mcp_sdk::schema::{CallToolResult, schema_utils::CallToolError}; + +#[mcp_tool( + name = "read_multiple_media_files", + title="Read Multiple Media (Image/Audio) Files", + description = concat!("Reads multiple image or audio files and returns their Base64-encoded contents along with corresponding MIME types. ", + "This method is more efficient than reading files individually. ", + "The max_bytes argument could be used to enforce an upper limit on the size of a file to read ", + "Failed reads for specific files are skipped without interrupting the entire operation. ", + "Only works within allowed directories."), + destructive_hint = false, + idempotent_hint = false, + open_world_hint = false, + read_only_hint = true +)] +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, JsonSchema)] +pub struct ReadMultipleMediaFilesTool { + /// The list of media file paths to read. + pub paths: Vec, + /// Maximum allowed file size (in bytes) to be read. + pub max_bytes: Option, +} + +impl ReadMultipleMediaFilesTool { + pub async fn run_tool( + params: Self, + context: &FileSystemService, + ) -> std::result::Result { + let result = context + .read_media_files(params.paths, params.max_bytes.map(|v| v as usize)) + .await + .map_err(CallToolError::new)?; + + let content: Vec<_> = result + .into_iter() + .filter_map(|(kind, content)| { + let mime_type = kind.mime_type().to_string(); + + match kind.matcher_type() { + infer::MatcherType::Image => Some(ContentBlock::ImageContent( + ImageContent::new(content, mime_type, None, None), + )), + infer::MatcherType::Audio => Some(ContentBlock::AudioContent( + AudioContent::new(content, mime_type, None, None), + )), + _ => None, + } + }) + .collect(); + + Ok(CallToolResult { + content, + is_error: None, + meta: None, + structured_content: None, + }) + } +} From b6dc664c18e2bcfa404a70e8f0edaa850720f9b7 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 19 Sep 2025 19:11:59 -0300 Subject: [PATCH 11/12] typo --- src/handler.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handler.rs b/src/handler.rs index 9afc36f..144d01b 100644 --- a/src/handler.rs +++ b/src/handler.rs @@ -87,7 +87,7 @@ impl FileSystemHandler { } else { let fs_service = self.fs_service.clone(); let mcp_roots_support = self.mcp_roots_support; - // retreive roots from the client and update the allowed dirctories accordingly + // retrieve roots from the client and update the allowed directories accordingly let roots = match runtime.clone().list_roots(None).await { Ok(roots_result) => roots_result.roots, Err(_err) => { From 9f0372d20b96c2f4149034c2e109a4b977a007f0 Mon Sep 17 00:00:00 2001 From: Ali Hashemi Date: Fri, 19 Sep 2025 19:13:34 -0300 Subject: [PATCH 12/12] cleanup --- src/fs_service.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/fs_service.rs b/src/fs_service.rs index 13dee04..39ecb11 100644 --- a/src/fs_service.rs +++ b/src/fs_service.rs @@ -43,7 +43,6 @@ const MAX_CONCURRENT_FILE_READ: usize = 5; type PathResultList = Vec>; -type PathResultList = Vec>; pub struct FileSystemService { allowed_path: RwLock>>, }