Files
filepuff-mcp/internal/server/server.go
T

428 lines
14 KiB
Go

// Package server implements the MCP server for file operations.
package server
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
"github.com/lukaszraczylo/mcp-filepuff/internal/edit"
"github.com/lukaszraczylo/mcp-filepuff/internal/lsp"
"github.com/lukaszraczylo/mcp-filepuff/internal/parser"
"github.com/lukaszraczylo/mcp-filepuff/internal/query"
"github.com/lukaszraczylo/mcp-filepuff/internal/search"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// MaxConcurrentReads limits concurrent file read operations to prevent memory exhaustion.
const MaxConcurrentReads = 10
// MaxConcurrentQueries limits concurrent AST query operations to prevent CPU exhaustion.
const MaxConcurrentQueries = 5
// ServerShutdownTimeout is the timeout for graceful server shutdown.
const ServerShutdownTimeout = 10 * time.Second
// PreviewLineMaxLength is the maximum length for preview lines before truncation.
const PreviewLineMaxLength = 100
// Server represents the MCP file operations server.
type Server struct {
cfg *config.Config
logger *slog.Logger
mcp *server.MCPServer
searcher *search.Searcher
parser *parser.Registry
matcher *query.Matcher
lspManager *lsp.Manager
editor *edit.Engine
readSem chan struct{} // Semaphore for limiting concurrent file reads
querySem chan struct{} // Semaphore for limiting concurrent AST queries
}
// New creates a new MCP server instance.
func New(cfg *config.Config, logger *slog.Logger) (*Server, error) {
parserRegistry := parser.NewRegistryWithSize(cfg.MaxParseSize)
s := &Server{
cfg: cfg,
logger: logger,
parser: parserRegistry,
matcher: query.NewMatcher(parserRegistry),
editor: edit.NewEngine(parserRegistry),
readSem: make(chan struct{}, MaxConcurrentReads),
querySem: make(chan struct{}, MaxConcurrentQueries),
}
// Initialize searcher
searcher, err := search.New(cfg, logger)
if err != nil {
logger.Warn("ripgrep not available, search functionality disabled", "error", err)
}
s.searcher = searcher
// Initialize LSP manager if enabled
if cfg.EnableLSP {
s.lspManager = lsp.NewManager(cfg.WorkspaceRoot, logger)
}
// Create MCP server
mcpServer := server.NewMCPServer(
"mcp-filepuff",
"1.0.0",
server.WithLogging(),
)
s.mcp = mcpServer
// Register tools
s.registerTools()
return s, nil
}
// registerTools registers all available tools with the MCP server.
func (s *Server) registerTools() {
// Register ping tool for health checks
s.mcp.AddTool(
mcp.NewTool("ping",
mcp.WithDescription("Health check - returns pong to verify the server is running"),
mcp.WithReadOnlyHintAnnotation(true),
),
s.handlePing,
)
// Register file_search tool
if s.searcher != nil {
s.mcp.AddTool(
mcp.NewTool("file_search",
mcp.WithDescription("Search for text patterns in files using ripgrep. Supports regex patterns, file type filtering, and context lines."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("pattern",
mcp.Required(),
mcp.Description("The search pattern (regex by default)"),
),
mcp.WithArray("paths",
mcp.Description("Paths to search in (defaults to workspace root)"),
mcp.WithStringItems(),
),
mcp.WithArray("file_types",
mcp.Description("File types to search (e.g., ['go', 'ts', 'py'])"),
mcp.WithStringItems(),
),
mcp.WithBoolean("ignore_case",
mcp.Description("Case insensitive search"),
),
mcp.WithBoolean("regex",
mcp.Description("Treat pattern as regex (default: true)"),
),
mcp.WithNumber("context_lines",
mcp.Description("Number of context lines around matches (default: 2)"),
),
mcp.WithNumber("max_results",
mcp.Description("Maximum number of results to return"),
),
),
s.handleFileSearch,
)
}
// Register file_read tool
s.mcp.AddTool(
mcp.NewTool("file_read",
mcp.WithDescription("Read a file's contents with optional line range and AST symbol summary"),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("path",
mcp.Required(),
mcp.Description("Path to the file to read"),
),
mcp.WithNumber("line_start",
mcp.Description("Starting line number (1-indexed)"),
),
mcp.WithNumber("line_end",
mcp.Description("Ending line number (inclusive)"),
),
mcp.WithBoolean("include_ast",
mcp.Description("Include AST symbol summary (functions, classes, types, etc.)"),
),
mcp.WithBoolean("symbols_only",
mcp.Description("Return only symbol summary without file content (token-efficient mode). Requires include_ast=true."),
),
mcp.WithNumber("max_lines",
mcp.Description("Maximum number of lines to return (for token efficiency). Applied after line_start/line_end."),
),
),
s.handleFileRead,
)
// Register ast_query tool
s.mcp.AddTool(
mcp.NewTool("ast_query",
mcp.WithDescription("Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("pattern",
mcp.Required(),
mcp.Description("Code pattern with placeholders: $NAME (single), $$$ARGS (multiple), $_ (wildcard). Examples: 'func $NAME($$$ARGS) error', 'class $NAME { $$$BODY }'"),
),
mcp.WithString("language",
mcp.Required(),
mcp.Description("Target language: go, typescript, javascript, python, c, cpp"),
),
mcp.WithArray("paths",
mcp.Description("Paths to search in (defaults to workspace root)"),
mcp.WithStringItems(),
),
mcp.WithString("name_matches",
mcp.Description("Regex pattern to filter by name"),
),
mcp.WithString("name_exact",
mcp.Description("Exact name to match"),
),
mcp.WithArray("kind_in",
mcp.Description("Node types to match (e.g., function_declaration, class_declaration)"),
mcp.WithStringItems(),
),
mcp.WithNumber("max_results",
mcp.Description("Maximum number of results to return (default: 100)"),
),
),
s.handleASTQuery,
)
// Register LSP-based tools if LSP is enabled
if s.lspManager != nil {
// Register symbol_at tool
s.mcp.AddTool(
mcp.NewTool("symbol_at",
mcp.WithDescription("Get information about the symbol at a specific position in a file. Returns type, documentation, and definition location using LSP when available."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("file",
mcp.Required(),
mcp.Description("Path to the file"),
),
mcp.WithNumber("line",
mcp.Required(),
mcp.Description("Line number (1-indexed)"),
),
mcp.WithNumber("column",
mcp.Required(),
mcp.Description("Column number (1-indexed)"),
),
),
s.handleSymbolAt,
)
// Register find_definition tool
s.mcp.AddTool(
mcp.NewTool("find_definition",
mcp.WithDescription("Find the definition of the symbol at a specific position. Uses LSP to locate where a function, variable, type, etc. is defined."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("file",
mcp.Required(),
mcp.Description("Path to the file"),
),
mcp.WithNumber("line",
mcp.Required(),
mcp.Description("Line number (1-indexed)"),
),
mcp.WithNumber("column",
mcp.Required(),
mcp.Description("Column number (1-indexed)"),
),
),
s.handleFindDefinition,
)
// Register find_references tool
s.mcp.AddTool(
mcp.NewTool("find_references",
mcp.WithDescription("Find all references to the symbol at a specific position. Uses LSP to locate all usages of a function, variable, type, etc."),
mcp.WithReadOnlyHintAnnotation(true),
mcp.WithString("file",
mcp.Required(),
mcp.Description("Path to the file"),
),
mcp.WithNumber("line",
mcp.Required(),
mcp.Description("Line number (1-indexed)"),
),
mcp.WithNumber("column",
mcp.Required(),
mcp.Description("Column number (1-indexed)"),
),
mcp.WithBoolean("include_declaration",
mcp.Description("Include the declaration in results (default: true)"),
),
),
s.handleFindReferences,
)
}
// Register edit tools
s.mcp.AddTool(
mcp.NewTool("edit_preview",
mcp.WithDescription("Preview an edit without applying it. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++), and text-based editing for other files (Markdown, JSON, YAML, config files, etc.)."),
mcp.WithString("file",
mcp.Required(),
mcp.Description("Path to the file to edit"),
),
mcp.WithString("operation",
mcp.Required(),
mcp.Description("Edit operation: replace, insert_before, insert_after, delete"),
),
mcp.WithString("new_content",
mcp.Description("New content (required for replace/insert operations)"),
),
// AST-mode selectors (for code files)
mcp.WithString("selector_kind",
mcp.Description("AST node type to match (e.g., function_declaration, class_declaration). For code files only."),
),
mcp.WithString("selector_name",
mcp.Description("Name of the symbol to match. For code files only."),
),
// Shared selectors
mcp.WithNumber("selector_line",
mcp.Description("Line number (1-indexed). For AST mode: narrows search. For text mode: start of line range."),
),
mcp.WithNumber("selector_index",
mcp.Description("Index of the match to use if multiple matches found (default: 0)"),
),
// Text-mode selectors (for non-code files or explicit text matching)
mcp.WithNumber("selector_line_end",
mcp.Description("End line number for range selection (text mode). Used with selector_line."),
),
mcp.WithString("selector_text",
mcp.Description("Exact text to match (text mode). Must be unique or use selector_index."),
),
mcp.WithString("selector_pattern",
mcp.Description("Regex pattern to match (text mode). Must be unique or use selector_index."),
),
),
s.handleEditPreview,
)
s.mcp.AddTool(
mcp.NewTool("edit_apply",
mcp.WithDescription("Apply an edit to a file. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++) with syntax validation, and text-based editing for other files (Markdown, JSON, YAML, config files, etc.)."),
mcp.WithString("file",
mcp.Required(),
mcp.Description("Path to the file to edit"),
),
mcp.WithString("operation",
mcp.Required(),
mcp.Description("Edit operation: replace, insert_before, insert_after, delete"),
),
mcp.WithString("new_content",
mcp.Description("New content (required for replace/insert operations)"),
),
// AST-mode selectors (for code files)
mcp.WithString("selector_kind",
mcp.Description("AST node type to match (e.g., function_declaration, class_declaration). For code files only."),
),
mcp.WithString("selector_name",
mcp.Description("Name of the symbol to match. For code files only."),
),
// Shared selectors
mcp.WithNumber("selector_line",
mcp.Description("Line number (1-indexed). For AST mode: narrows search. For text mode: start of line range."),
),
mcp.WithNumber("selector_index",
mcp.Description("Index of the match to use if multiple matches found (default: 0)"),
),
// Text-mode selectors (for non-code files or explicit text matching)
mcp.WithNumber("selector_line_end",
mcp.Description("End line number for range selection (text mode). Used with selector_line."),
),
mcp.WithString("selector_text",
mcp.Description("Exact text to match (text mode). Must be unique or use selector_index."),
),
mcp.WithString("selector_pattern",
mcp.Description("Regex pattern to match (text mode). Must be unique or use selector_index."),
),
),
s.handleEditApply,
)
}
// handlePing handles the ping health check tool.
func (s *Server) handlePing(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
return mcp.NewToolResultText("pong"), nil
}
// Run starts the MCP server and blocks until shutdown.
func (s *Server) Run(ctx context.Context) error {
// Set up signal handling for graceful shutdown
ctx, cancel := context.WithCancel(ctx)
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer signal.Stop(sigChan)
// Channel to communicate server errors
errChan := make(chan error, 1)
// Start server in goroutine
go func() {
s.logger.Info("starting MCP server",
"workspace", s.cfg.WorkspaceRoot,
"lsp_enabled", s.cfg.EnableLSP,
)
errChan <- server.ServeStdio(s.mcp)
}()
// Wait for either signal or server error
select {
case sig := <-sigChan:
s.logger.Info("received shutdown signal", "signal", sig)
// Create timeout context for shutdown
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), ServerShutdownTimeout)
defer shutdownCancel()
// Call graceful shutdown
if err := s.Shutdown(shutdownCtx); err != nil {
s.logger.Error("error during shutdown", "error", err)
return err
}
s.logger.Info("server shutdown complete")
return nil
case err := <-errChan:
// Server stopped on its own
return err
case <-ctx.Done():
// Context cancelled externally
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), ServerShutdownTimeout)
defer shutdownCancel()
if err := s.Shutdown(shutdownCtx); err != nil {
s.logger.Error("error during shutdown", "error", err)
}
return ctx.Err()
}
}
// Shutdown gracefully shuts down the server.
func (s *Server) Shutdown(ctx context.Context) error {
s.logger.Info("shutting down MCP server")
// Close LSP manager
if s.lspManager != nil {
_ = s.lspManager.Close()
}
// Close parser registry
if s.parser != nil {
s.parser.Close()
}
return nil
}