Files
filepuff-mcp/internal/server/handlers_edit.go
T
lukaszraczylo a11623cb0d perf(edit_apply): drop redundant Diff: label; docs(file_read): note strip renumbering
edit_apply response=diff prefixed the fenced ```diff block with a 'Diff:'
label that the fence and the diff's own ---/+++ headers already convey — pure
token cost per diff response. Extract fencedDiff() (also dedups the diff and
default branches) and drop the label. Adds a test guard.

Also document that strip renumbers lines from 1 over the stripped content, so
the model doesn't cite original-file line numbers after a stripped read.
2026-06-20 02:12:46 +01:00

134 lines
4.2 KiB
Go

// Package server implements the MCP server for file operations.
package server
import (
"context"
"fmt"
"strings"
"github.com/lukaszraczylo/mcp-filepuff/internal/edit"
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
"github.com/mark3labs/mcp-go/mcp"
)
// handleEditApply handles the edit_apply tool.
func (s *Server) handleEditApply(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return s.handleEdit(ctx, request)
}
// handleEdit performs an edit operation (always applies changes).
func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
file, err := request.RequireString("file")
if err != nil {
return mcp.NewToolResultError("file is required"), nil
}
operation, err := request.RequireString("operation")
if err != nil {
return mcp.NewToolResultError("operation is required"), nil
}
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
}
if !s.cfg.IsPathAllowed(file) {
return mcp.NewToolResultError("file is outside workspace root"), nil
}
// new_content arrives already fully decoded by the JSON-RPC layer (mcp-go):
// JSON escapes such as \n, \t, \\, \" have been resolved to their real bytes.
// It must therefore be used verbatim — any further unescaping would corrupt
// legitimate backslash sequences in source code (string literals, regexes,
// Windows paths). See TestEditApplyPreservesBackslashSequences*.
newContent := request.GetString("new_content", "")
selectorName := request.GetString("selector_name", "")
astEdit := &edit.ASTEdit{
File: file,
Operation: edit.EditOperation(operation),
NewContent: newContent,
Selector: edit.ASTSelector{
Kind: request.GetString("selector_kind", ""),
Name: selectorName,
AtLine: request.GetInt("selector_line", 0),
Index: request.GetInt("selector_index", 0),
LineEnd: request.GetInt("selector_line_end", 0),
Text: request.GetString("selector_text", ""),
TextPattern: request.GetString("selector_pattern", ""),
},
}
result, err := s.editor.Apply(ctx, astEdit)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("edit failed: %s", errors.SanitizeError(err))), nil
}
if !result.Success {
return mcp.NewToolResultError(result.Error), nil
}
// Determine response mode.
// compact_response is a deprecated alias for response="count".
respMode := request.GetString("response", "count")
if request.GetBool("compact_response", false) {
// Deprecated: use response=count
respMode = "count"
}
switch respMode {
case "none":
return mcp.NewToolResultText(""), nil
case "count":
// Compute +added/-removed line counts from the unified diff.
added, removed := countDiffLines(result.Diff)
return mcp.NewToolResultText(fmt.Sprintf("+%d -%d", added, removed)), nil
case "diff":
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
default:
// Fallback: treat unknown values as "diff" for safety.
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
}
}
// fencedDiff wraps a unified diff in a ```diff code fence. No "Diff:" label is
// added — the fence and the diff's own ---/+++ headers already identify it, so
// the label would only cost tokens.
func fencedDiff(diff string) string {
return "```diff\n" + diff + "```\n"
}
// countDiffLines counts added (+) and removed (-) content lines in a unified diff.
// Only lines inside hunks are counted (everything after the first "@@" header), so the
// "---"/"+++" file headers are skipped structurally — and content whose own text starts
// with + or - is counted correctly rather than mistaken for a header. The git-style
// "\ No newline at end of file" marker is ignored.
func countDiffLines(diff string) (added, removed int) {
inHunk := false
for _, line := range strings.Split(diff, "\n") {
if strings.HasPrefix(line, "@@") {
inHunk = true
continue
}
if !inHunk || line == "" {
continue
}
switch line[0] {
case '+':
added++
case '-':
removed++
}
}
return
}