fixup! Update, bugfixes on diff and edit handling

This commit is contained in:
2026-02-22 14:03:54 +00:00
parent 6980d3b294
commit 982c2c8b44
23 changed files with 655 additions and 194 deletions
+25 -14
View File
@@ -54,9 +54,9 @@ func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest
}
// Find files to search based on language
ext := languageToExtension(language)
if ext == "" {
return mcp.NewToolResultError(fmt.Sprintf("unsupported language: %s", language)), nil
exts := languageToExtensions(language)
if len(exts) == 0 {
return mcp.NewToolResultError(fmt.Sprintf("unsupported language: %s (supported: go, typescript, javascript, python, c, cpp, html, vue, elixir)", language)), nil
}
var allResults []query.MatchResult
@@ -89,7 +89,14 @@ func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest
}
// Check file extension matches language
if !strings.HasSuffix(path, ext) {
matched := false
for _, ext := range exts {
if strings.HasSuffix(path, ext) {
matched = true
break
}
}
if !matched {
return nil
}
@@ -203,24 +210,28 @@ func symbolKindIcon(kind protocol.SymbolKind) string {
}
}
// languageToExtension maps language names to file extensions.
func languageToExtension(language string) string {
// languageToExtensions maps language names to file extensions.
func languageToExtensions(language string) []string {
switch strings.ToLower(language) {
case "go":
return ".go"
return []string{".go"}
case "typescript":
return ".ts"
return []string{".ts"}
case "javascript":
return ".js"
return []string{".js"}
case "python":
return ".py"
return []string{".py"}
case "c":
return ".c"
return []string{".c"}
case "cpp", "c++":
return ".cpp"
return []string{".cpp"}
case "html":
return []string{".html", ".htm"}
case "vue":
return []string{".vue"}
case "elixir":
return ".ex"
return []string{".ex", ".exs"}
default:
return ""
return nil
}
}
+10
View File
@@ -33,6 +33,16 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest, ap
return mcp.NewToolResultError("operation is required"), nil
}
// Validate operation against known values
switch edit.EditOperation(operation) {
case edit.EditReplace, edit.EditInsertBefore, edit.EditInsertAfter, edit.EditDelete:
// valid
default:
return mcp.NewToolResultError(fmt.Sprintf(
"invalid operation %q: must be one of: replace, insert_before, insert_after, delete", operation,
)), nil
}
// Validate path
if !s.cfg.IsPathAllowed(file) {
return mcp.NewToolResultError("file is outside workspace root"), nil
+22 -9
View File
@@ -81,8 +81,8 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
return mcp.NewToolResultError("path is outside workspace root"), nil
}
// Read file
content, err := os.ReadFile(path)
// Check file size before reading to avoid loading huge files into memory
info, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
return mcp.NewToolResultError(fmt.Sprintf("file not found: %s", path)), nil
@@ -90,13 +90,21 @@ func (s *Server) handleFileRead(ctx context.Context, request mcp.CallToolRequest
if os.IsPermission(err) {
return mcp.NewToolResultError(fmt.Sprintf("permission denied: %s", path)), nil
}
s.logger.Warn("file read error", "path", path, "error", err)
return mcp.NewToolResultError("error reading file"), nil
s.logger.Warn("file stat error", "path", path, "error", err)
return mcp.NewToolResultError("error accessing file"), nil
}
if info.Size() > s.cfg.MaxFileSize {
return mcp.NewToolResultError(fmt.Sprintf("file too large (%d bytes, max %d)", info.Size(), s.cfg.MaxFileSize)), nil
}
// Check file size
if int64(len(content)) > s.cfg.MaxFileSize {
return mcp.NewToolResultError(fmt.Sprintf("file too large (%d bytes, max %d)", len(content), s.cfg.MaxFileSize)), nil
// Read file
content, err := os.ReadFile(path)
if err != nil {
if os.IsPermission(err) {
return mcp.NewToolResultError(fmt.Sprintf("permission denied: %s", path)), nil
}
s.logger.Warn("file read error", "path", path, "error", err)
return mcp.NewToolResultError("error reading file"), nil
}
// Handle line range
@@ -167,13 +175,18 @@ func splitLines(s string) []string {
const largeSizeThreshold = 1024 * 1024 // 1MB
if len(s) > largeSizeThreshold {
// Use scanner for large files
// Use scanner for large files with increased buffer for long lines
scanner := bufio.NewScanner(strings.NewReader(s))
scanner.Buffer(make([]byte, 0, bufio.MaxScanTokenSize), 1024*1024) // up to 1MB per line
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
// Handle potential error and add empty line if string ended with newline
if err := scanner.Err(); err != nil {
// If scanning fails (e.g. line exceeds buffer), fall back to strings.Split
return strings.Split(s, "\n")
}
// Add empty line if string ended with newline
if len(s) > 0 && s[len(s)-1] == '\n' {
lines = append(lines, "")
}
+8 -2
View File
@@ -117,7 +117,7 @@ func (s *Server) handleFindDefinition(ctx context.Context, request mcp.CallToolR
output.WriteString(fmt.Sprintf("**%s:%d:%d**\n", filePath, loc.Range.Start.Line+1, loc.Range.Start.Character+1))
// Try to read a preview snippet
preview := readFilePreview(filePath, loc.Range.Start.Line+1, 3)
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
if preview != "" {
output.WriteString("```\n")
output.WriteString(preview)
@@ -184,7 +184,13 @@ func (s *Server) handleFindReferences(ctx context.Context, request mcp.CallToolR
}
// readFilePreview reads a few lines from a file around the given line.
func readFilePreview(file string, line, contextLines int) string {
// It validates that the file path is within the allowed workspace before reading.
func (s *Server) readFilePreview(file string, line, contextLines int) string {
if !s.cfg.IsPathAllowed(file) {
s.logger.Warn("readFilePreview: path not allowed", "path", file)
return ""
}
content, err := os.ReadFile(file)
if err != nil {
return ""
+1 -1
View File
@@ -169,7 +169,7 @@ func (s *Server) registerTools() {
),
mcp.WithString("language",
mcp.Required(),
mcp.Description("Target language: go, typescript, javascript, python, c, cpp"),
mcp.Description("Target language: go, typescript, javascript, python, c, cpp, html, vue, elixir"),
),
mcp.WithArray("paths",
mcp.Description("Paths to search in (defaults to workspace root)"),
+153
View File
@@ -5,6 +5,7 @@ import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
@@ -381,3 +382,155 @@ func Hello() {
t.Error("handleEdit(apply) should modify the file")
}
}
// TestHandleFileReadMaxFileSize verifies that handleFileRead rejects files
// exceeding MaxFileSize via os.Stat before loading them into memory (T-03, S-01).
func TestHandleFileReadMaxFileSize(t *testing.T) {
tmpDir := t.TempDir()
// Create a test file
testFile := filepath.Join(tmpDir, "big.txt")
content := make([]byte, 1024) // 1KB file
for i := range content {
content[i] = 'A'
}
if err := os.WriteFile(testFile, content, 0600); err != nil {
t.Fatalf("failed to write test file: %v", err)
}
// Set MaxFileSize smaller than the file
cfg := &config.Config{
WorkspaceRoot: tmpDir,
EnableLSP: false,
MaxFileSize: 512, // 512 bytes — smaller than our 1KB file
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))
srv, err := New(cfg, logger)
if err != nil {
t.Fatalf("New() error = %v", err)
}
ctx := context.Background()
req := mcp.CallToolRequest{}
req.Params.Arguments = map[string]interface{}{
"path": testFile,
}
result, err := srv.handleFileRead(ctx, req)
if err != nil {
t.Fatalf("handleFileRead() returned Go error: %v", err)
}
// The result should indicate an error (file too large)
if result == nil {
t.Fatal("handleFileRead() returned nil result")
}
if !result.IsError {
t.Error("expected IsError=true for file exceeding MaxFileSize")
}
contents := result.Content
if len(contents) == 0 {
t.Fatal("expected non-empty content with error message")
}
textContent, ok := contents[0].(mcp.TextContent)
if !ok {
t.Fatal("expected text content")
}
if !strings.Contains(textContent.Text, "too large") {
t.Errorf("expected 'too large' error message, got: %s", textContent.Text)
}
}
// TestSplitLinesLargeFile tests the splitLines function with a large file (>1MB)
// to exercise the bufio.Scanner path including the scanner.Err() check (T-07, C-05).
func TestSplitLinesLargeFile(t *testing.T) {
// Build a string >1MB with known line count
lineCount := 20000
var sb strings.Builder
for i := 0; i < lineCount; i++ {
sb.WriteString(strings.Repeat("x", 60))
sb.WriteByte('\n')
}
largeContent := sb.String()
// Verify it's large enough to trigger the scanner path
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes, need >1MB", len(largeContent))
}
lines := splitLines(largeContent)
// String ends with \n, so splitLines adds an empty trailing element
// (matching the behavior of strings.Split for the small-file path)
expectedLines := lineCount + 1 // lineCount lines + 1 trailing empty
if len(lines) != expectedLines {
t.Errorf("splitLines returned %d lines, expected %d", len(lines), expectedLines)
}
// Check first and last actual lines
if lines[0] != strings.Repeat("x", 60) {
t.Errorf("first line mismatch: got %q", lines[0][:20])
}
if lines[lineCount-1] != strings.Repeat("x", 60) {
t.Errorf("last content line mismatch: got %q", lines[lineCount-1][:20])
}
if lines[lineCount] != "" {
t.Errorf("expected empty trailing line, got %q", lines[lineCount])
}
}
// TestSplitLinesLargeFileNoTrailingNewline verifies splitLines for large files
// without a trailing newline.
func TestSplitLinesLargeFileNoTrailingNewline(t *testing.T) {
lineCount := 20000
var sb strings.Builder
for i := 0; i < lineCount; i++ {
if i > 0 {
sb.WriteByte('\n')
}
sb.WriteString(strings.Repeat("y", 60))
}
largeContent := sb.String()
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes", len(largeContent))
}
lines := splitLines(largeContent)
if len(lines) != lineCount {
t.Errorf("splitLines returned %d lines, expected %d", len(lines), lineCount)
}
}
// TestSplitLinesLongLine verifies the scanner gracefully handles very long lines
// (up to the 1MB buffer limit set in C-05 fix).
func TestSplitLinesLongLine(t *testing.T) {
// Create content with one very long line (500KB) embedded in a >1MB file
shortLines := strings.Repeat("short line content\n", 60000) // ~60KB * ~1 = ~1.08MB
longLine := strings.Repeat("L", 500*1024) // 500KB line
largeContent := shortLines + longLine + "\n"
if len(largeContent) <= 1024*1024 {
t.Fatalf("test content too small: %d bytes", len(largeContent))
}
lines := splitLines(largeContent)
// Should not crash and should return some lines
if len(lines) == 0 {
t.Fatal("splitLines returned no lines for valid content")
}
// The long line should be present somewhere in the output
foundLong := false
for _, line := range lines {
if len(line) >= 500*1024 {
foundLong = true
break
}
}
if !foundLong {
t.Error("the 500KB long line was not found in splitLines output")
}
}