mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-06-05 22:23:50 +00:00
428 lines
14 KiB
Go
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
|
|
}
|