Skip to content

Commit 7c31c4c

Browse files
committed
Merge branch 'main' of github.com:rust-mcp-stack/rust-mcp-filesystem
2 parents 0232a2d + 25d6e3b commit 7c31c4c

4 files changed

Lines changed: 137 additions & 6 deletions

File tree

src/fs_service/io/read.rs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,25 @@ use tokio::{
2020
const MAX_CONCURRENT_FILE_READ: usize = 5;
2121

2222
impl FileSystemService {
23-
pub async fn read_text_file(&self, file_path: &Path) -> ServiceResult<String> {
23+
pub async fn read_text_file(
24+
&self,
25+
file_path: &Path,
26+
with_line_numbers: bool,
27+
) -> ServiceResult<String> {
2428
let allowed_directories = self.allowed_directories().await;
2529
let valid_path = self.validate_path(file_path, allowed_directories)?;
2630
let content = tokio::fs::read_to_string(valid_path).await?;
27-
Ok(content)
31+
32+
if with_line_numbers {
33+
Ok(content
34+
.lines()
35+
.enumerate()
36+
.map(|(i, line)| format!("{:>6} | {}", i + 1, line))
37+
.collect::<Vec<_>>()
38+
.join("\n"))
39+
} else {
40+
Ok(content)
41+
}
2842
}
2943

3044
/// Reads the first n lines from a text file, preserving line endings.

src/tools/read_multiple_text_files.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ impl ReadMultipleTextFiles {
3535
.map(|path| async move {
3636
{
3737
let content = context
38-
.read_text_file(Path::new(&path))
38+
.read_text_file(Path::new(&path), false)
3939
.await
4040
.map_err(CallToolError::new);
4141

src/tools/read_text_file.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ use crate::fs_service::FileSystemService;
1212
description = concat!("Read the complete contents of a text file from the file system as text. ",
1313
"Handles various text encodings and provides detailed error messages if the ",
1414
"file cannot be read. Use this tool when you need to examine the contents of ",
15-
"a single file. Only works within allowed directories."),
15+
"a single file. Optionally include line numbers for precise code targeting. ",
16+
"Only works within allowed directories."),
1617
destructive_hint = false,
1718
idempotent_hint = false,
1819
open_world_hint = false,
@@ -22,6 +23,11 @@ use crate::fs_service::FileSystemService;
2223
pub struct ReadTextFile {
2324
/// The path of the file to read.
2425
pub path: String,
26+
/// Optional: Include line numbers in output (default: false).
27+
/// When enabled, each line is prefixed with a right-aligned, 1-based line number
28+
/// Followed by a space, a vertical bar (`|`), and another space in the format: ` 123 | <original line content>`
29+
#[serde(default)]
30+
pub with_line_numbers: Option<bool>,
2531
}
2632

2733
impl ReadTextFile {
@@ -30,7 +36,10 @@ impl ReadTextFile {
3036
context: &FileSystemService,
3137
) -> std::result::Result<CallToolResult, CallToolError> {
3238
let content = context
33-
.read_text_file(Path::new(&params.path))
39+
.read_text_file(
40+
Path::new(&params.path),
41+
params.with_line_numbers.unwrap_or(false),
42+
)
3443
.await
3544
.map_err(CallToolError::new)?;
3645

tests/test_fs_service.rs

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,10 +230,118 @@ async fn test_unzip_file_non_existent() {
230230
async fn test_read_file() {
231231
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
232232
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "test.txt", "content");
233-
let content = service.read_text_file(&file_path).await.unwrap();
233+
let content = service.read_text_file(&file_path, false).await.unwrap();
234234
assert_eq!(content, "content");
235235
}
236236

237+
#[tokio::test]
238+
async fn test_read_text_file_with_line_numbers() {
239+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
240+
let file_path = create_temp_file(
241+
temp_dir.join("dir1").as_path(),
242+
"test.txt",
243+
"line1\nline2\nline3",
244+
);
245+
let content = service.read_text_file(&file_path, true).await.unwrap();
246+
assert_eq!(content, " 1 | line1\n 2 | line2\n 3 | line3");
247+
}
248+
249+
#[tokio::test]
250+
async fn test_read_text_file_without_line_numbers() {
251+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
252+
let file_path = create_temp_file(
253+
temp_dir.join("dir1").as_path(),
254+
"test.txt",
255+
"line1\nline2\nline3",
256+
);
257+
let content = service.read_text_file(&file_path, false).await.unwrap();
258+
assert_eq!(content, "line1\nline2\nline3");
259+
}
260+
261+
#[tokio::test]
262+
async fn test_read_text_file_with_line_numbers_empty_file() {
263+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
264+
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "empty.txt", "");
265+
let content = service.read_text_file(&file_path, true).await.unwrap();
266+
assert_eq!(content, "");
267+
}
268+
269+
#[tokio::test]
270+
async fn test_read_text_file_with_line_numbers_single_line() {
271+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
272+
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "single.txt", "single line");
273+
let content = service.read_text_file(&file_path, true).await.unwrap();
274+
assert_eq!(content, " 1 | single line");
275+
}
276+
277+
#[tokio::test]
278+
async fn test_read_text_file_with_line_numbers_no_trailing_newline() {
279+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
280+
let file_path = create_temp_file(
281+
temp_dir.join("dir1").as_path(),
282+
"no_newline.txt",
283+
"line1\nline2",
284+
);
285+
let content = service.read_text_file(&file_path, true).await.unwrap();
286+
assert_eq!(content, " 1 | line1\n 2 | line2");
287+
}
288+
289+
#[tokio::test]
290+
async fn test_read_text_file_with_line_numbers_large_file() {
291+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
292+
// Create a file with more than 999 lines to test padding
293+
let mut lines = Vec::new();
294+
for i in 1..=1000 {
295+
lines.push(format!("line{i}"));
296+
}
297+
let file_content = lines.join("\n");
298+
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "large.txt", &file_content);
299+
let content = service.read_text_file(&file_path, true).await.unwrap();
300+
301+
// Check first line
302+
assert!(content.starts_with(" 1 | line1\n"));
303+
// Check line 999
304+
assert!(content.contains(" 999 | line999\n"));
305+
// Check line 1000 (6 digits with right padding)
306+
assert!(content.contains(" 1000 | line1000"));
307+
}
308+
309+
#[tokio::test]
310+
async fn test_read_text_file_with_line_numbers_windows_line_endings() {
311+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
312+
let file_path = create_temp_file(
313+
temp_dir.join("dir1").as_path(),
314+
"windows.txt",
315+
"line1\r\nline2\r\nline3",
316+
);
317+
let content = service.read_text_file(&file_path, true).await.unwrap();
318+
assert_eq!(content, " 1 | line1\n 2 | line2\n 3 | line3");
319+
}
320+
321+
#[tokio::test]
322+
async fn test_read_text_file_with_line_numbers_single_newline_unix() {
323+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
324+
// A file with just "\n" is treated by lines() as having one empty line before the newline
325+
// To get two empty lines, we need "\n\n"
326+
let file_path = create_temp_file(temp_dir.join("dir1").as_path(), "newline_unix.txt", "\n\n");
327+
let content = service.read_text_file(&file_path, true).await.unwrap();
328+
assert_eq!(content, " 1 | \n 2 | ");
329+
}
330+
331+
#[tokio::test]
332+
async fn test_read_text_file_with_line_numbers_single_newline_windows() {
333+
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);
334+
// A file with just "\r\n" is treated by lines() as having one empty line
335+
// To get two empty lines, we need "\r\n\r\n"
336+
let file_path = create_temp_file(
337+
temp_dir.join("dir1").as_path(),
338+
"newline_windows.txt",
339+
"\r\n\r\n",
340+
);
341+
let content = service.read_text_file(&file_path, true).await.unwrap();
342+
assert_eq!(content, " 1 | \n 2 | ");
343+
}
344+
237345
#[tokio::test]
238346
async fn test_create_directory() {
239347
let (temp_dir, service, _allowed_dirs) = setup_service(vec!["dir1".to_string()]);

0 commit comments

Comments
 (0)