mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
References defaulted to one line per reference; compact (one line per file, columns collapsed) existed only as opt-in. Compact preserves every line:col pair with zero information loss while cutting roughly one line per reference for the LLM consumer. Default it to true; compact=false and the session compact_refs pref still select the verbose per-reference layout (explicit override wins).
325 lines
12 KiB
Go
325 lines
12 KiB
Go
// Package server implements the MCP server for file operations.
|
|
package server
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
|
|
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
|
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
|
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
|
"github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
if !s.cfg.IsPathAllowed(file) {
|
|
return mcp.NewToolResultError("file is outside workspace root"), nil
|
|
}
|
|
|
|
// Position can be given directly (line+column) or resolved from a symbol name.
|
|
var line, col int
|
|
if symbolName := request.GetString("symbol_name", ""); symbolName != "" {
|
|
symbolKind := protocol.SymbolKind(request.GetString("symbol_kind", ""))
|
|
resolvedLine, resolvedCol, errResult := s.resolveLSPSymbolPosition(ctx, file, symbolName, symbolKind)
|
|
if errResult != nil {
|
|
return errResult, nil
|
|
}
|
|
line, col = resolvedLine, resolvedCol
|
|
} else {
|
|
line = request.GetInt("line", 0)
|
|
if line <= 0 {
|
|
return mcp.NewToolResultError("line must be positive (or provide symbol_name)"), nil
|
|
}
|
|
col = request.GetInt("column", 0)
|
|
if col <= 0 {
|
|
return mcp.NewToolResultError("column must be positive (or provide symbol_name)"), nil
|
|
}
|
|
}
|
|
|
|
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 > true.
|
|
// Compact (one line per file) is the default — it preserves every
|
|
// line:col pair while cutting ~one line per reference for the LLM consumer.
|
|
var prefsCompact *bool
|
|
if sp := s.sessionPrefs.Load(); sp != nil {
|
|
prefsCompact = sp.CompactRefs
|
|
}
|
|
compact := effectiveBool(request, "compact", prefsCompact, true)
|
|
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
|
|
}
|
|
}
|
|
|
|
// resolveLSPSymbolPosition resolves a symbol name to the 1-indexed line:column of
|
|
// its name identifier, for use as an LSP position. Returns an error result when the
|
|
// file can't be read/parsed, the symbol is not found (with a "did you mean" hint),
|
|
// or the name is ambiguous (listing candidates).
|
|
func (s *Server) resolveLSPSymbolPosition(ctx context.Context, file, symbolName string, symbolKind protocol.SymbolKind) (line, col int, errResult *mcp.CallToolResult) {
|
|
content, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to read file: %s", errors.SanitizeError(err)))
|
|
}
|
|
result, err := s.parser.Parse(ctx, file, content)
|
|
if err != nil {
|
|
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err)))
|
|
}
|
|
candidates := parser.FindSymbolCandidates(result.Tree, content, file, symbolName, symbolKind)
|
|
switch len(candidates) {
|
|
case 0:
|
|
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q not found in %s%s", symbolName, file, s.nearestSymbolSuggestion(ctx, file, content, symbolName)))
|
|
case 1:
|
|
return candidates[0].NameLine, candidates[0].NameColumn, nil
|
|
default:
|
|
return 0, 0, mcp.NewToolResultError(fmt.Sprintf("symbol %q is ambiguous in %s (%d matches): %s — disambiguate with symbol_kind, or pass line+column",
|
|
symbolName, file, len(candidates), formatSymbolCandidates(candidates)))
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
return s.handleSymbolAtFallback(ctx, file, line, col, verbose)
|
|
}
|
|
|
|
if hover == nil {
|
|
return mcp.NewToolResultText("No symbol information available at this position."), nil
|
|
}
|
|
|
|
var output strings.Builder
|
|
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, 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
|
|
}
|
|
|
|
result, err := s.parser.Parse(ctx, file, content)
|
|
if err != nil {
|
|
return mcp.NewToolResultError(fmt.Sprintf("failed to parse file: %s", errors.SanitizeError(err))), nil
|
|
}
|
|
|
|
node := parser.FindNodeAtPosition(result.Tree, line, col)
|
|
if node == nil {
|
|
return mcp.NewToolResultText("No symbol at this position."), nil
|
|
}
|
|
|
|
var output strings.Builder
|
|
if verbose {
|
|
output.WriteString("**Symbol Information** (AST fallback — LSP unavailable)\n\n")
|
|
} else {
|
|
// Always signal the degraded result so the model knows this is an AST
|
|
// guess, not real LSP type info, and can decide whether to retry.
|
|
output.WriteString("[AST fallback — LSP unavailable]\n")
|
|
}
|
|
fmt.Fprintf(&output, "Node type: `%s`\n", node.Type())
|
|
fmt.Fprintf(&output, "Text: `%s`\n", parser.GetNodeText(node, content))
|
|
|
|
return mcp.NewToolResultText(output.String()), 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
|
|
}
|
|
|
|
if len(locations) == 0 {
|
|
return mcp.NewToolResultText("No definition found."), nil
|
|
}
|
|
|
|
var output strings.Builder
|
|
if verbose {
|
|
fmt.Fprintf(&output, "Found %d definition(s):\n\n", len(locations))
|
|
}
|
|
|
|
for _, loc := range locations {
|
|
filePath := lsp.URIToFile(loc.URI)
|
|
fmt.Fprintf(&output, "**%s:%d:%d**\n", s.relPath(filePath), loc.Range.Start.Line+1, loc.Range.Start.Character+1)
|
|
|
|
preview := s.readFilePreview(filePath, loc.Range.Start.Line+1, 3)
|
|
if preview != "" {
|
|
output.WriteString("```\n")
|
|
output.WriteString(preview)
|
|
output.WriteString("```\n")
|
|
}
|
|
output.WriteString("\n")
|
|
}
|
|
|
|
return mcp.NewToolResultText(output.String()), 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
|
|
}
|
|
|
|
if len(locations) == 0 {
|
|
return mcp.NewToolResultText("No references found."), nil
|
|
}
|
|
|
|
// Group by file, preserving encounter order.
|
|
fileGroups := make(map[string][]lsp.Location)
|
|
fileOrder := make([]string, 0)
|
|
for _, loc := range locations {
|
|
// Relativize for display; references are not re-read so the absolute
|
|
// path isn't needed downstream.
|
|
filePath := s.relPath(lsp.URIToFile(loc.URI))
|
|
if _, seen := fileGroups[filePath]; !seen {
|
|
fileOrder = append(fileOrder, filePath)
|
|
}
|
|
fileGroups[filePath] = append(fileGroups[filePath], loc)
|
|
}
|
|
|
|
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 {
|
|
fmt.Fprintf(&output, "Found %d reference(s):\n\n", total)
|
|
}
|
|
|
|
for _, filePath := range fileOrder {
|
|
locs := fileGroups[filePath]
|
|
if compact {
|
|
output.WriteString(formatReferencesCompact(filePath, locs))
|
|
} else {
|
|
fmt.Fprintf(&output, "**%s** (%d)\n", filePath, len(locs))
|
|
for _, loc := range locs {
|
|
fmt.Fprintf(&output, " 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.
|
|
// 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 ""
|
|
}
|
|
|
|
lines := splitLines(string(content))
|
|
startLine := max(1, line-contextLines)
|
|
endLine := min(line+contextLines, len(lines))
|
|
|
|
var preview strings.Builder
|
|
for i := startLine - 1; i < endLine && i < len(lines); i++ {
|
|
lineText := lines[i]
|
|
if len(lineText) > PreviewLineMaxLength {
|
|
lineText = lineText[:PreviewLineMaxLength] + "..."
|
|
}
|
|
prefix := " "
|
|
if i+1 == line {
|
|
prefix = "> "
|
|
}
|
|
fmt.Fprintf(&preview, "%s%4d: %s\n", prefix, i+1, lineText)
|
|
}
|
|
|
|
return preview.String()
|
|
}
|