mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-06-06 22:33:42 +00:00
5ad975ee7a
* v2.0: token-optimization overhaul Additive (backward-compatible flags): - file_read: skeleton mode, strip (imports/license/block_comments), compact_line_numbers, 8-char etag with prefix-match compat - ast_query: format=verbose|compact|location, pagination cursor - file_search: cluster mode, pagination cursor - lsp_query (references): compact output Breaking (v2): - Preambles removed; opt-in verbose=true restores - edit_apply: response=count|diff|none, default count - ping tool removed - symbol_at/find_definition/find_references merged into lsp_query - Tool descriptions trimmed -83%, help moved to filepuff://help/<tool> - Batch file_read dedups by etag Protocol: - ResourceLink returned for file_read >64 KiB (force_inline override) - OnAfterInitialize hook reads capabilities.experimental.filepuff for session defaults (default_format, default_max_results, default_cluster, compact_refs, line_numbers, resource_link_threshold) * fix: drop --max-total-count from ripgrep args The flag does not exist in stable ripgrep (confirmed up to 15.1.0 -- "unrecognized flag --max-total-count, similar flags that are available: --max-count"). Every file_search call failed on hosts with stock rg. --max-count is per-file, not a drop-in replacement, so rely on the in-process truncation in parseOutput that was already the documented safety net.
136 lines
3.9 KiB
Go
136 lines
3.9 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"
|
|
)
|
|
|
|
// unescapeNewlines converts literal \n, \t, \" sequences to actual characters.
|
|
// This handles cases where MCP clients send double-escaped JSON strings.
|
|
func unescapeNewlines(s string) string {
|
|
s = strings.ReplaceAll(s, "\\n", "\n")
|
|
s = strings.ReplaceAll(s, "\\t", "\t")
|
|
s = strings.ReplaceAll(s, "\\\"", "\"")
|
|
s = strings.ReplaceAll(s, "\\\\", "\\")
|
|
return s
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
newContent := request.GetString("new_content", "")
|
|
newContent = unescapeNewlines(newContent)
|
|
|
|
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":
|
|
var output strings.Builder
|
|
output.WriteString("Diff:\n```diff\n")
|
|
output.WriteString(result.Diff)
|
|
output.WriteString("```\n")
|
|
return mcp.NewToolResultText(output.String()), nil
|
|
|
|
default:
|
|
// Fallback: treat unknown values as "diff" for safety.
|
|
var output strings.Builder
|
|
output.WriteString("Diff:\n```diff\n")
|
|
output.WriteString(result.Diff)
|
|
output.WriteString("```\n")
|
|
return mcp.NewToolResultText(output.String()), nil
|
|
}
|
|
}
|
|
|
|
// countDiffLines counts added (+) and removed (-) lines in a unified diff string.
|
|
func countDiffLines(diff string) (added, removed int) {
|
|
for _, line := range strings.Split(diff, "\n") {
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
switch line[0] {
|
|
case '+':
|
|
if !strings.HasPrefix(line, "+++") {
|
|
added++
|
|
}
|
|
case '-':
|
|
if !strings.HasPrefix(line, "---") {
|
|
removed++
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|