Skip to content

Commit 534d803

Browse files
authored
feat: add new tool for reading media files (Image / Audio) (#43)
* roots support * feat: handle roots from client * chore: better messaging when allowed_directory list is empty * chore: better messaging * update tests and cleanup * update docs * typo * clippy * add read_media tool * add read multiple media files * typo * cleanup
1 parent df715f1 commit 534d803

12 files changed

+350
-68
lines changed

Cargo.lock

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ futures = "0.3"
3737
tokio-util = "0.7"
3838
async_zip = { version = "0.0", features = ["full"] }
3939
grep = "0.3"
40+
base64 = "0.22"
41+
infer = "0.19.0"
4042

4143
[dev-dependencies]
4244
tempfile = "3.2"

src/error.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,10 @@ pub enum ServiceError {
3434
ZipError(#[from] ZipError),
3535
#[error("{0}")]
3636
GlobPatternError(#[from] PatternError),
37+
#[error("File size exceeds the maximum allowed limit of {0} bytes")]
38+
FileTooLarge(usize),
39+
#[error("File size is below the minimum required limit of {0} bytes")]
40+
FileTooSmall(usize),
41+
#[error("The file is either not an image/audio type or is unsupported (mime:{0}).")]
42+
InvalidMediaFile(String),
3743
}

src/fs_service.rs

Lines changed: 108 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ use crate::{
55
tools::EditOperation,
66
};
77
use async_zip::tokio::{read::seek::ZipFileReader, write::ZipFileWriter};
8+
use base64::{engine::general_purpose, write::EncoderWriter};
89
use file_info::FileInfo;
10+
use futures::{StreamExt, stream};
911
use glob::Pattern;
1012
use grep::{
1113
matcher::{Match, Matcher},
@@ -19,12 +21,13 @@ use std::{
1921
collections::HashSet,
2022
env,
2123
fs::{self},
24+
io::Write,
2225
path::{Path, PathBuf},
2326
sync::Arc,
2427
};
2528
use tokio::{
26-
fs::File,
27-
io::{AsyncWriteExt, BufReader},
29+
fs::{File, metadata},
30+
io::{AsyncReadExt, AsyncWriteExt, BufReader},
2831
sync::RwLock,
2932
};
3033
use tokio_util::compat::{FuturesAsyncReadCompatExt, TokioAsyncReadCompatExt};
@@ -36,6 +39,7 @@ use walkdir::WalkDir;
3639

3740
const SNIPPET_MAX_LENGTH: usize = 200;
3841
const SNIPPET_BACKWARD_CHARS: usize = 30;
42+
const MAX_CONCURRENT_FILE_READ: usize = 5;
3943

4044
type PathResultList = Vec<Result<PathBuf, ServiceError>>;
4145

@@ -432,7 +436,108 @@ impl FileSystemService {
432436
Ok(result_message)
433437
}
434438

435-
pub async fn read_file(&self, file_path: &Path) -> ServiceResult<String> {
439+
pub fn mime_from_path(&self, path: &Path) -> ServiceResult<infer::Type> {
440+
let is_svg = path
441+
.extension()
442+
.is_some_and(|e| e.to_str().is_some_and(|s| s == "svg"));
443+
// consider it is a svg file as we cannot detect svg from bytes pattern
444+
if is_svg {
445+
return Ok(infer::Type::new(
446+
infer::MatcherType::Image,
447+
"image/svg+xml",
448+
"svg",
449+
|_: &[u8]| true,
450+
));
451+
452+
// infer::Type::new(infer::MatcherType::Image, "", "svg",);
453+
}
454+
let kind = infer::get_from_path(path)?.ok_or(ServiceError::FromString(
455+
"File tyle is unknown!".to_string(),
456+
))?;
457+
Ok(kind)
458+
}
459+
460+
pub async fn validate_file_size<P: AsRef<Path>>(
461+
&self,
462+
path: P,
463+
min_bytes: Option<usize>,
464+
max_bytes: Option<usize>,
465+
) -> ServiceResult<()> {
466+
if min_bytes.is_none() && max_bytes.is_none() {
467+
return Ok(());
468+
}
469+
470+
let file_size = metadata(&path).await?.len() as usize;
471+
472+
match (min_bytes, max_bytes) {
473+
(_, Some(max)) if file_size > max => Err(ServiceError::FileTooLarge(max)),
474+
(Some(min), _) if file_size < min => Err(ServiceError::FileTooSmall(min)),
475+
_ => Ok(()),
476+
}
477+
}
478+
479+
pub async fn read_media_files(
480+
&self,
481+
paths: Vec<String>,
482+
max_bytes: Option<usize>,
483+
) -> ServiceResult<Vec<(infer::Type, String)>> {
484+
let results = stream::iter(paths)
485+
.map(|path| async {
486+
self.read_media_file(Path::new(&path), max_bytes)
487+
.await
488+
.map_err(|e| (path, e))
489+
})
490+
.buffer_unordered(MAX_CONCURRENT_FILE_READ) // Process up to MAX_CONCURRENT_FILE_READ files concurrently
491+
.filter_map(|result| async move { result.ok() })
492+
.collect::<Vec<_>>()
493+
.await;
494+
Ok(results)
495+
}
496+
497+
pub async fn read_media_file(
498+
&self,
499+
file_path: &Path,
500+
max_bytes: Option<usize>,
501+
) -> ServiceResult<(infer::Type, String)> {
502+
let allowed_directories = self.allowed_directories().await;
503+
let valid_path = self.validate_path(file_path, allowed_directories)?;
504+
self.validate_file_size(&valid_path, None, max_bytes)
505+
.await?;
506+
let kind = self.mime_from_path(&valid_path)?;
507+
let content = self.read_file_as_base64(&valid_path).await?;
508+
Ok((kind, content))
509+
}
510+
511+
// reads file as base64 efficiently in a streaming manner
512+
async fn read_file_as_base64(&self, file_path: &Path) -> ServiceResult<String> {
513+
let file = File::open(file_path).await?;
514+
let mut reader = BufReader::new(file);
515+
516+
let mut output = Vec::new();
517+
{
518+
// Wrap output Vec<u8> in a Base64 encoder writer
519+
let mut encoder = EncoderWriter::new(&mut output, &general_purpose::STANDARD);
520+
521+
let mut buffer = [0u8; 8192];
522+
loop {
523+
let n = reader.read(&mut buffer).await?;
524+
if n == 0 {
525+
break;
526+
}
527+
// Write raw bytes to the Base64 encoder
528+
encoder.write_all(&buffer[..n])?;
529+
}
530+
// Make sure to flush any remaining bytes
531+
encoder.flush()?;
532+
} // drop encoder before consuming output
533+
534+
// Convert the Base64 bytes to String (safe UTF-8)
535+
let base64_string =
536+
String::from_utf8(output).map_err(|err| ServiceError::FromString(format!("{err}")))?;
537+
Ok(base64_string)
538+
}
539+
540+
pub async fn read_text_file(&self, file_path: &Path) -> ServiceResult<String> {
436541
let allowed_directories = self.allowed_directories().await;
437542
let valid_path = self.validate_path(file_path, allowed_directories)?;
438543
let content = tokio::fs::read_to_string(valid_path).await?;

src/handler.rs

Lines changed: 43 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -88,47 +88,45 @@ impl FileSystemHandler {
8888
let fs_service = self.fs_service.clone();
8989
let mcp_roots_support = self.mcp_roots_support;
9090
// retrieve roots from the client and update the allowed directories accordingly
91-
tokio::spawn(async move {
92-
let roots = match runtime.clone().list_roots(None).await {
93-
Ok(roots_result) => roots_result.roots,
94-
Err(_err) => {
95-
vec![]
96-
}
97-
};
98-
99-
let valid_roots = if roots.is_empty() {
91+
let roots = match runtime.clone().list_roots(None).await {
92+
Ok(roots_result) => roots_result.roots,
93+
Err(_err) => {
10094
vec![]
101-
} else {
102-
let roots: Vec<_> = roots.iter().map(|v| v.uri.as_str()).collect();
103-
104-
match fs_service.valid_roots(roots) {
105-
Ok((roots, skipped)) => {
106-
if let Some(message) = skipped {
107-
let _ = runtime.stderr_message(message.to_string()).await;
108-
}
109-
roots
95+
}
96+
};
97+
98+
let valid_roots = if roots.is_empty() {
99+
vec![]
100+
} else {
101+
let roots: Vec<_> = roots.iter().map(|v| v.uri.as_str()).collect();
102+
103+
match fs_service.valid_roots(roots) {
104+
Ok((roots, skipped)) => {
105+
if let Some(message) = skipped {
106+
let _ = runtime.stderr_message(message.to_string()).await;
110107
}
111-
Err(_err) => vec![],
108+
roots
112109
}
113-
};
110+
Err(_err) => vec![],
111+
}
112+
};
114113

115-
if valid_roots.is_empty() && !mcp_roots_support {
116-
let message = if allowed_directories.is_empty() {
117-
"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."
118-
} else {
119-
"Client provided empty roots. Allowed directories passed from command-line will be used."
120-
};
121-
let _ = runtime.stderr_message(message.to_string()).await;
114+
if valid_roots.is_empty() && !mcp_roots_support {
115+
let message = if allowed_directories.is_empty() {
116+
"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."
122117
} else {
123-
let num_valid_roots = valid_roots.len();
118+
"Client provided empty roots. Allowed directories passed from command-line will be used."
119+
};
120+
let _ = runtime.stderr_message(message.to_string()).await;
121+
} else {
122+
let num_valid_roots = valid_roots.len();
124123

125-
fs_service.update_allowed_paths(valid_roots).await;
126-
let message = format!(
127-
"Updated allowed directories from MCP roots: {num_valid_roots} valid directories",
128-
);
129-
let _ = runtime.stderr_message(message.to_string()).await;
130-
}
131-
});
124+
fs_service.update_allowed_paths(valid_roots).await;
125+
let message = format!(
126+
"Updated allowed directories from MCP roots: {num_valid_roots} valid directories",
127+
);
128+
let _ = runtime.stderr_message(message.to_string()).await;
129+
}
132130
}
133131
}
134132
}
@@ -196,11 +194,17 @@ impl ServerHandler for FileSystemHandler {
196194
}
197195

198196
match tool_params {
199-
FileSystemTools::ReadFileTool(params) => {
200-
ReadFileTool::run_tool(params, &self.fs_service).await
197+
FileSystemTools::ReadMediaFileTool(params) => {
198+
ReadMediaFileTool::run_tool(params, &self.fs_service).await
199+
}
200+
FileSystemTools::ReadMultipleMediaFilesTool(params) => {
201+
ReadMultipleMediaFilesTool::run_tool(params, &self.fs_service).await
202+
}
203+
FileSystemTools::ReadTextFileTool(params) => {
204+
ReadTextFileTool::run_tool(params, &self.fs_service).await
201205
}
202-
FileSystemTools::ReadMultipleFilesTool(params) => {
203-
ReadMultipleFilesTool::run_tool(params, &self.fs_service).await
206+
FileSystemTools::ReadMultipleTextFilesTool(params) => {
207+
ReadMultipleTextFilesTool::run_tool(params, &self.fs_service).await
204208
}
205209
FileSystemTools::WriteFileTool(params) => {
206210
WriteFileTool::run_tool(params, &self.fs_service).await

0 commit comments

Comments
 (0)