Skip to content

Commit bc5bf14

Browse files
author
Dimitar Grigorov
committed
Added tool read_multiple_files
1 parent f8c70b3 commit bc5bf14

File tree

4 files changed

+207
-0
lines changed

4 files changed

+207
-0
lines changed
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package handler
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"sync"
8+
9+
"github.com/modelcontextprotocol/go-sdk/mcp"
10+
)
11+
12+
// HandleReadMultipleFiles reads multiple files concurrently.
13+
// Individual file failures don't stop the operation - errors are reported per file.
14+
func (h *Handler) HandleReadMultipleFiles(ctx context.Context, req *mcp.CallToolRequest, input ReadMultipleFilesInput) (*mcp.CallToolResult, ReadMultipleFilesOutput, error) {
15+
if len(input.Paths) == 0 {
16+
return errorResult("paths array is required and must contain at least one path"), ReadMultipleFilesOutput{}, nil
17+
}
18+
19+
results := make([]FileReadResult, len(input.Paths))
20+
var wg sync.WaitGroup
21+
22+
for i, path := range input.Paths {
23+
wg.Add(1)
24+
go func(idx int, filePath string) {
25+
defer wg.Done()
26+
results[idx] = h.readSingleFile(filePath, input.Encoding)
27+
}(i, path)
28+
}
29+
30+
wg.Wait()
31+
32+
var successCount, errorCount int
33+
for _, r := range results {
34+
if r.Error != "" {
35+
errorCount++
36+
} else {
37+
successCount++
38+
}
39+
}
40+
41+
return &mcp.CallToolResult{}, ReadMultipleFilesOutput{
42+
Results: results,
43+
SuccessCount: successCount,
44+
ErrorCount: errorCount,
45+
}, nil
46+
}
47+
48+
// readSingleFile reads a single file with optional encoding.
49+
func (h *Handler) readSingleFile(path, requestedEncoding string) FileReadResult {
50+
result := FileReadResult{Path: path}
51+
52+
v := h.ValidatePath(path)
53+
if !v.Ok() {
54+
result.Error = v.Err.Error()
55+
return result
56+
}
57+
58+
data, err := os.ReadFile(v.Path)
59+
if err != nil {
60+
result.Error = fmt.Sprintf("failed to read file: %v", err)
61+
return result
62+
}
63+
64+
encResult, err := resolveEncoding(requestedEncoding, data)
65+
if err != nil {
66+
result.Error = err.Error()
67+
return result
68+
}
69+
70+
content, err := decodeContent(data, encResult)
71+
if err != nil {
72+
result.Error = fmt.Sprintf("failed to decode file content: %v", err)
73+
return result
74+
}
75+
76+
result.Content = content
77+
if encResult.autoDetected {
78+
result.DetectedEncoding = encResult.detectedEncoding
79+
result.EncodingConfidence = encResult.encodingConfidence
80+
}
81+
82+
return result
83+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package handler
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/dimitar-grigorov/mcp-file-tools/internal/encoding"
11+
)
12+
13+
func TestHandleReadMultipleFiles_Success(t *testing.T) {
14+
tempDir := t.TempDir()
15+
h := NewHandler([]string{tempDir})
16+
17+
file1 := filepath.Join(tempDir, "file1.txt")
18+
file2 := filepath.Join(tempDir, "file2.txt")
19+
os.WriteFile(file1, []byte("content1"), 0644)
20+
os.WriteFile(file2, []byte("content2"), 0644)
21+
22+
input := ReadMultipleFilesInput{Paths: []string{file1, file2}}
23+
result, output, err := h.HandleReadMultipleFiles(context.Background(), nil, input)
24+
25+
if err != nil || result.IsError {
26+
t.Fatal("expected success")
27+
}
28+
if output.SuccessCount != 2 || output.ErrorCount != 0 {
29+
t.Errorf("expected 2 successes, got %d successes, %d errors", output.SuccessCount, output.ErrorCount)
30+
}
31+
if output.Results[0].Content != "content1" || output.Results[1].Content != "content2" {
32+
t.Errorf("unexpected content")
33+
}
34+
}
35+
36+
func TestHandleReadMultipleFiles_PartialFailure(t *testing.T) {
37+
tempDir := t.TempDir()
38+
h := NewHandler([]string{tempDir})
39+
40+
file1 := filepath.Join(tempDir, "exists.txt")
41+
file2 := filepath.Join(tempDir, "nonexistent.txt")
42+
os.WriteFile(file1, []byte("content1"), 0644)
43+
44+
input := ReadMultipleFilesInput{Paths: []string{file1, file2}}
45+
result, output, _ := h.HandleReadMultipleFiles(context.Background(), nil, input)
46+
47+
if result.IsError {
48+
t.Error("expected partial success, not tool error")
49+
}
50+
if output.SuccessCount != 1 || output.ErrorCount != 1 {
51+
t.Errorf("expected 1 success, 1 error, got %d/%d", output.SuccessCount, output.ErrorCount)
52+
}
53+
}
54+
55+
func TestHandleReadMultipleFiles_EmptyPaths(t *testing.T) {
56+
h := NewHandler([]string{t.TempDir()})
57+
result, _, _ := h.HandleReadMultipleFiles(context.Background(), nil, ReadMultipleFilesInput{Paths: []string{}})
58+
if !result.IsError {
59+
t.Error("expected error for empty paths")
60+
}
61+
}
62+
63+
func TestHandleReadMultipleFiles_WithEncoding(t *testing.T) {
64+
tempDir := t.TempDir()
65+
h := NewHandler([]string{tempDir})
66+
67+
enc, _ := encoding.Get("cp1251")
68+
cp1251Bytes, _ := enc.NewEncoder().Bytes([]byte("Здравей свят!"))
69+
file1 := filepath.Join(tempDir, "cyrillic.txt")
70+
os.WriteFile(file1, cp1251Bytes, 0644)
71+
72+
input := ReadMultipleFilesInput{Paths: []string{file1}, Encoding: "cp1251"}
73+
_, output, _ := h.HandleReadMultipleFiles(context.Background(), nil, input)
74+
75+
if !strings.Contains(output.Results[0].Content, "Здравей свят!") {
76+
t.Errorf("expected Cyrillic content, got %q", output.Results[0].Content)
77+
}
78+
}
79+
80+
func TestHandleReadMultipleFiles_PathOutsideAllowed(t *testing.T) {
81+
tempDir := t.TempDir()
82+
h := NewHandler([]string{tempDir})
83+
84+
input := ReadMultipleFilesInput{Paths: []string{filepath.Join(tempDir, "..", "..", "etc", "passwd")}}
85+
_, output, _ := h.HandleReadMultipleFiles(context.Background(), nil, input)
86+
87+
if !strings.Contains(output.Results[0].Error, "access denied") {
88+
t.Errorf("expected 'access denied' error, got %q", output.Results[0].Error)
89+
}
90+
}

filetoolsserver/handler/types.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,27 @@ type EditFileOutput struct {
192192
Diff string `json:"diff"`
193193
}
194194

195+
// ReadMultipleFilesInput defines input parameters for read_multiple_files tool.
196+
// Paths: Array of file paths to read (required, min 1)
197+
// Encoding: Encoding for all files - auto-detected per file if not specified (optional)
198+
type ReadMultipleFilesInput struct {
199+
Paths []string `json:"paths"`
200+
Encoding string `json:"encoding,omitempty"`
201+
}
202+
203+
// FileReadResult represents the result of reading a single file
204+
type FileReadResult struct {
205+
Path string `json:"path"`
206+
Content string `json:"content,omitempty"`
207+
Error string `json:"error,omitempty"`
208+
DetectedEncoding string `json:"detectedEncoding,omitempty"`
209+
EncodingConfidence int `json:"encodingConfidence,omitempty"`
210+
}
211+
212+
// ReadMultipleFilesOutput defines output for read_multiple_files tool
213+
type ReadMultipleFilesOutput struct {
214+
Results []FileReadResult `json:"results"`
215+
SuccessCount int `json:"successCount"`
216+
ErrorCount int `json:"errorCount"`
217+
}
218+

filetoolsserver/server.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,16 @@ func NewServer(allowedDirs []string, logger *slog.Logger, cfg *config.Config) *m
6464
},
6565
}, handler.Wrap(logger, "read_text_file", h.HandleReadTextFile))
6666

67+
mcp.AddTool(server, &mcp.Tool{
68+
Name: "read_multiple_files",
69+
Description: "Read the contents of multiple files simultaneously. More efficient than reading files one by one. Each file's content is returned with encoding info. Individual file failures don't stop the operation. Parameters: paths (required array of file paths), encoding (optional, applies to all files - auto-detected per file if not specified).",
70+
Annotations: &mcp.ToolAnnotations{
71+
Title: "Read Multiple Files",
72+
ReadOnlyHint: true,
73+
OpenWorldHint: boolPtr(false),
74+
},
75+
}, handler.Wrap(logger, "read_multiple_files", h.HandleReadMultipleFiles))
76+
6777
mcp.AddTool(server, &mcp.Tool{
6878
Name: "list_directory",
6979
Description: "List files and directories with optional glob pattern filtering (e.g., *.pas, *.dfm). Parameters: path (required), pattern (optional, default: *).",

0 commit comments

Comments
 (0)