mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-21 06:07:12 +00:00
V2/token optimization (#11)
* 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.
This commit is contained in:
+131
-68
@@ -13,8 +13,14 @@ import (
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
)
|
||||
|
||||
// handleSymbolAt handles the symbol_at tool.
|
||||
func (s *Server) handleSymbolAt(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
// handleLSPQuery is the unified dispatcher for all LSP operations.
|
||||
// action must be one of: "hover", "definition", "references".
|
||||
func (s *Server) handleLSPQuery(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
action, err := request.RequireString("action")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("action is required (hover | definition | references)"), nil
|
||||
}
|
||||
|
||||
file, err := request.RequireString("file")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("file is required"), nil
|
||||
@@ -30,16 +36,51 @@ func (s *Server) handleSymbolAt(ctx context.Context, request mcp.CallToolRequest
|
||||
return mcp.NewToolResultError("column must be positive"), nil
|
||||
}
|
||||
|
||||
// Validate path
|
||||
if !s.cfg.IsPathAllowed(file) {
|
||||
return mcp.NewToolResultError("file is outside workspace root"), nil
|
||||
}
|
||||
|
||||
// Try LSP hover
|
||||
verbose := request.GetBool("verbose", false)
|
||||
|
||||
switch action {
|
||||
case "hover":
|
||||
if _, ok := request.GetArguments()["include_declaration"]; ok {
|
||||
return mcp.NewToolResultError("include_declaration is only valid for action=references"), nil
|
||||
}
|
||||
if _, ok := request.GetArguments()["compact"]; ok {
|
||||
return mcp.NewToolResultError("compact is only valid for action=references"), nil
|
||||
}
|
||||
return s.lspHover(ctx, file, line, col, verbose)
|
||||
|
||||
case "definition":
|
||||
if _, ok := request.GetArguments()["include_declaration"]; ok {
|
||||
return mcp.NewToolResultError("include_declaration is only valid for action=references"), nil
|
||||
}
|
||||
if _, ok := request.GetArguments()["compact"]; ok {
|
||||
return mcp.NewToolResultError("compact is only valid for action=references"), nil
|
||||
}
|
||||
return s.lspDefinition(ctx, file, line, col, verbose)
|
||||
|
||||
case "references":
|
||||
includeDecl := request.GetBool("include_declaration", true)
|
||||
// compact: explicit call-time > session compact_refs pref > false
|
||||
var prefsCompact *bool
|
||||
if sp := s.sessionPrefs.Load(); sp != nil {
|
||||
prefsCompact = sp.CompactRefs
|
||||
}
|
||||
compact := effectiveBool(request, "compact", prefsCompact, false)
|
||||
return s.lspReferences(ctx, file, line, col, includeDecl, compact, verbose)
|
||||
|
||||
default:
|
||||
return mcp.NewToolResultError(fmt.Sprintf("unknown action %q: must be hover | definition | references", action)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// lspHover performs hover (symbol info) for the given position.
|
||||
func (s *Server) lspHover(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
|
||||
hover, err := s.lspManager.Hover(ctx, file, line, col)
|
||||
if err != nil {
|
||||
// Fall back to AST-based info
|
||||
return s.handleSymbolAtFallback(ctx, file, line, col)
|
||||
return s.handleSymbolAtFallback(ctx, file, line, col, verbose)
|
||||
}
|
||||
|
||||
if hover == nil {
|
||||
@@ -47,14 +88,16 @@ func (s *Server) handleSymbolAt(ctx context.Context, request mcp.CallToolRequest
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString("**Symbol Information**\n\n")
|
||||
if verbose {
|
||||
output.WriteString("**Symbol Information**\n\n")
|
||||
}
|
||||
output.WriteString(hover.Contents.Value)
|
||||
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
}
|
||||
|
||||
// handleSymbolAtFallback provides AST-based symbol info when LSP is unavailable.
|
||||
func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line, col int) (*mcp.CallToolResult, error) {
|
||||
func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
|
||||
content, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err))), nil
|
||||
@@ -71,35 +114,17 @@ func (s *Server) handleSymbolAtFallback(ctx context.Context, file string, line,
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString("**Symbol Information** (AST fallback)\n\n")
|
||||
if verbose {
|
||||
output.WriteString("**Symbol Information** (AST fallback)\n\n")
|
||||
}
|
||||
output.WriteString(fmt.Sprintf("Node type: `%s`\n", node.Type()))
|
||||
output.WriteString(fmt.Sprintf("Text: `%s`\n", parser.GetNodeText(node, content)))
|
||||
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
}
|
||||
|
||||
// handleFindDefinition handles the find_definition tool.
|
||||
func (s *Server) handleFindDefinition(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
file, err := request.RequireString("file")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("file is required"), nil
|
||||
}
|
||||
|
||||
line := request.GetInt("line", 0)
|
||||
if line <= 0 {
|
||||
return mcp.NewToolResultError("line must be positive"), nil
|
||||
}
|
||||
|
||||
col := request.GetInt("column", 0)
|
||||
if col <= 0 {
|
||||
return mcp.NewToolResultError("column must be positive"), nil
|
||||
}
|
||||
|
||||
// Validate path
|
||||
if !s.cfg.IsPathAllowed(file) {
|
||||
return mcp.NewToolResultError("file is outside workspace root"), nil
|
||||
}
|
||||
|
||||
// lspDefinition finds the definition of the symbol at the given position.
|
||||
func (s *Server) lspDefinition(ctx context.Context, file string, line, col int, verbose bool) (*mcp.CallToolResult, error) {
|
||||
locations, err := s.lspManager.Definition(ctx, file, line, col)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("definition lookup failed: %s", errors.SanitizeError(err))), nil
|
||||
@@ -110,13 +135,14 @@ func (s *Server) handleFindDefinition(ctx context.Context, request mcp.CallToolR
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString(fmt.Sprintf("Found %d definition(s):\n\n", len(locations)))
|
||||
if verbose {
|
||||
output.WriteString(fmt.Sprintf("Found %d definition(s):\n\n", len(locations)))
|
||||
}
|
||||
|
||||
for _, loc := range locations {
|
||||
filePath := lsp.URIToFile(loc.URI)
|
||||
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 := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
||||
if preview != "" {
|
||||
output.WriteString("```\n")
|
||||
@@ -129,30 +155,8 @@ func (s *Server) handleFindDefinition(ctx context.Context, request mcp.CallToolR
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
}
|
||||
|
||||
// handleFindReferences handles the find_references tool.
|
||||
func (s *Server) handleFindReferences(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
file, err := request.RequireString("file")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError("file is required"), nil
|
||||
}
|
||||
|
||||
line := request.GetInt("line", 0)
|
||||
if line <= 0 {
|
||||
return mcp.NewToolResultError("line must be positive"), nil
|
||||
}
|
||||
|
||||
col := request.GetInt("column", 0)
|
||||
if col <= 0 {
|
||||
return mcp.NewToolResultError("column must be positive"), nil
|
||||
}
|
||||
|
||||
includeDecl := request.GetBool("include_declaration", true)
|
||||
|
||||
// Validate path
|
||||
if !s.cfg.IsPathAllowed(file) {
|
||||
return mcp.NewToolResultError("file is outside workspace root"), nil
|
||||
}
|
||||
|
||||
// lspReferences finds all references to the symbol at the given position.
|
||||
func (s *Server) lspReferences(ctx context.Context, file string, line, col int, includeDecl, compact, verbose bool) (*mcp.CallToolResult, error) {
|
||||
locations, err := s.lspManager.References(ctx, file, line, col, includeDecl)
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(fmt.Sprintf("references lookup failed: %s", errors.SanitizeError(err))), nil
|
||||
@@ -162,25 +166,84 @@ func (s *Server) handleFindReferences(ctx context.Context, request mcp.CallToolR
|
||||
return mcp.NewToolResultText("No references found."), nil
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
output.WriteString(fmt.Sprintf("Found %d reference(s):\n\n", len(locations)))
|
||||
|
||||
// Group by file
|
||||
// Group by file, preserving encounter order.
|
||||
fileGroups := make(map[string][]lsp.Location)
|
||||
fileOrder := make([]string, 0)
|
||||
for _, loc := range locations {
|
||||
filePath := lsp.URIToFile(loc.URI)
|
||||
if _, seen := fileGroups[filePath]; !seen {
|
||||
fileOrder = append(fileOrder, filePath)
|
||||
}
|
||||
fileGroups[filePath] = append(fileGroups[filePath], loc)
|
||||
}
|
||||
|
||||
for filePath, locs := range fileGroups {
|
||||
output.WriteString(fmt.Sprintf("**%s** (%d)\n", filePath, len(locs)))
|
||||
for _, loc := range locs {
|
||||
output.WriteString(fmt.Sprintf(" L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1))
|
||||
}
|
||||
output.WriteString("\n")
|
||||
return mcp.NewToolResultText(formatReferences(fileGroups, fileOrder, len(locations), compact, verbose)), nil
|
||||
}
|
||||
|
||||
// formatReferences formats grouped reference locations as a string.
|
||||
// compact=false: verbose multi-line format with L{line}:{col} per entry.
|
||||
// compact=true: one line per file — file:[line:col, ...] (N), with same-line
|
||||
// columns collapsed to line:{col1,col2,...}.
|
||||
func formatReferences(fileGroups map[string][]lsp.Location, fileOrder []string, total int, compact bool, verbose bool) string {
|
||||
var output strings.Builder
|
||||
if verbose {
|
||||
output.WriteString(fmt.Sprintf("Found %d reference(s):\n\n", total))
|
||||
}
|
||||
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
for _, filePath := range fileOrder {
|
||||
locs := fileGroups[filePath]
|
||||
if compact {
|
||||
output.WriteString(formatReferencesCompact(filePath, locs))
|
||||
} else {
|
||||
output.WriteString(fmt.Sprintf("**%s** (%d)\n", filePath, len(locs)))
|
||||
for _, loc := range locs {
|
||||
output.WriteString(fmt.Sprintf(" L%d:%d\n", loc.Range.Start.Line+1, loc.Range.Start.Character+1))
|
||||
}
|
||||
output.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return output.String()
|
||||
}
|
||||
|
||||
// formatReferencesCompact formats one file's references as a single compact line.
|
||||
// Same-line references are collapsed: 12:{5,8} instead of 12:5, 12:8.
|
||||
func formatReferencesCompact(filePath string, locs []lsp.Location) string {
|
||||
// Build ordered line->col map preserving encounter order per line.
|
||||
type lineEntry struct {
|
||||
lineNum int
|
||||
cols []int
|
||||
}
|
||||
lineMap := make(map[int]*lineEntry)
|
||||
lineOrder := make([]int, 0, len(locs))
|
||||
|
||||
for _, loc := range locs {
|
||||
ln := loc.Range.Start.Line + 1
|
||||
col := loc.Range.Start.Character + 1
|
||||
if e, ok := lineMap[ln]; ok {
|
||||
e.cols = append(e.cols, col)
|
||||
} else {
|
||||
lineMap[ln] = &lineEntry{lineNum: ln, cols: []int{col}}
|
||||
lineOrder = append(lineOrder, ln)
|
||||
}
|
||||
}
|
||||
|
||||
// Build the bracket contents.
|
||||
parts := make([]string, 0, len(lineOrder))
|
||||
for _, ln := range lineOrder {
|
||||
e := lineMap[ln]
|
||||
if len(e.cols) == 1 {
|
||||
parts = append(parts, fmt.Sprintf("%d:%d", ln, e.cols[0]))
|
||||
} else {
|
||||
colStrs := make([]string, len(e.cols))
|
||||
for i, c := range e.cols {
|
||||
colStrs[i] = fmt.Sprintf("%d", c)
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("%d:{%s}", ln, strings.Join(colStrs, ",")))
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s:[%s] (%d)\n", filePath, strings.Join(parts, ", "), len(locs))
|
||||
}
|
||||
|
||||
// readFilePreview reads a few lines from a file around the given line.
|
||||
|
||||
Reference in New Issue
Block a user