mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-06-08 22:49:14 +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.
233 lines
7.0 KiB
Go
233 lines
7.0 KiB
Go
// Package config provides configuration management for the MCP file operations server.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
json "github.com/goccy/go-json"
|
|
)
|
|
|
|
// Config holds all configuration options for the MCP server.
|
|
type Config struct {
|
|
Formatters map[string]string `json:"formatters"`
|
|
WorkspaceRoot string `json:"workspace_root"`
|
|
LSPTimeout time.Duration `json:"lsp_timeout"`
|
|
SearchTimeout time.Duration `json:"search_timeout"`
|
|
MaxFileSize int64 `json:"max_file_size"`
|
|
MaxParseSize int64 `json:"max_parse_size"`
|
|
MaxSearchResults int `json:"max_search_results"`
|
|
MaxEditSize int64 `json:"max_edit_size"`
|
|
EnableLSP bool `json:"enable_lsp"`
|
|
FollowSymlinks bool `json:"follow_symlinks"`
|
|
RespectGitignore bool `json:"respect_gitignore"`
|
|
ResourceLinkThresholdBytes int `json:"resource_link_threshold_bytes"`
|
|
}
|
|
|
|
// Default values for configuration.
|
|
const (
|
|
DefaultLSPTimeout = 5 * time.Minute
|
|
DefaultSearchTimeout = 30 * time.Second
|
|
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
|
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
|
|
DefaultMaxSearchResults = 1000
|
|
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
|
DefaultResourceLinkThresholdBytes = 64 * 1024 // 64 KiB
|
|
)
|
|
|
|
// Default returns a Config with default values.
|
|
func Default() *Config {
|
|
return &Config{
|
|
WorkspaceRoot: ".",
|
|
LSPTimeout: DefaultLSPTimeout,
|
|
SearchTimeout: DefaultSearchTimeout,
|
|
MaxFileSize: DefaultMaxFileSize,
|
|
MaxParseSize: DefaultMaxParseSize,
|
|
MaxSearchResults: DefaultMaxSearchResults,
|
|
MaxEditSize: DefaultMaxEditSize,
|
|
EnableLSP: true,
|
|
Formatters: make(map[string]string),
|
|
FollowSymlinks: true,
|
|
RespectGitignore: true,
|
|
ResourceLinkThresholdBytes: DefaultResourceLinkThresholdBytes,
|
|
}
|
|
}
|
|
|
|
// Load loads configuration from environment variables and optional config file.
|
|
// Priority: CLI flags > environment variables > config file > defaults.
|
|
func Load(workspaceRoot string) (*Config, error) {
|
|
cfg := Default()
|
|
|
|
// Set workspace root
|
|
if workspaceRoot != "" {
|
|
absPath, err := filepath.Abs(workspaceRoot)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cfg.WorkspaceRoot = absPath
|
|
} else if cwd, err := os.Getwd(); err == nil {
|
|
cfg.WorkspaceRoot = cwd
|
|
}
|
|
|
|
// Try to load from config file in workspace root.
|
|
// Save WorkspaceRoot before loading config file so it cannot be overridden.
|
|
savedRoot := cfg.WorkspaceRoot
|
|
configPath := filepath.Join(cfg.WorkspaceRoot, ".mcp-filepuff.json")
|
|
if data, err := os.ReadFile(configPath); err == nil {
|
|
if err := json.Unmarshal(data, cfg); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
// Restore WorkspaceRoot — config file must not override path guards.
|
|
cfg.WorkspaceRoot = savedRoot
|
|
|
|
// Clamp size limits to prevent config file from requesting excessive memory.
|
|
const maxAllowedSize int64 = 100 * 1024 * 1024 // 100 MB
|
|
if cfg.MaxFileSize > maxAllowedSize {
|
|
cfg.MaxFileSize = maxAllowedSize
|
|
}
|
|
if cfg.MaxParseSize > maxAllowedSize {
|
|
cfg.MaxParseSize = maxAllowedSize
|
|
}
|
|
|
|
// Override from environment variables
|
|
cfg.loadFromEnv()
|
|
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c *Config) loadFromEnv() {
|
|
if v := os.Getenv("MCP_WORKSPACE_ROOT"); v != "" {
|
|
if absPath, err := filepath.Abs(v); err == nil {
|
|
c.WorkspaceRoot = absPath
|
|
}
|
|
}
|
|
|
|
if v := os.Getenv("MCP_LSP_TIMEOUT"); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil {
|
|
c.LSPTimeout = d
|
|
}
|
|
}
|
|
|
|
if v := os.Getenv("MCP_SEARCH_TIMEOUT"); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil {
|
|
c.SearchTimeout = d
|
|
}
|
|
}
|
|
|
|
if v := os.Getenv("MCP_ENABLE_LSP"); v == "false" || v == "0" {
|
|
c.EnableLSP = false
|
|
}
|
|
|
|
if v := os.Getenv("MCP_FOLLOW_SYMLINKS"); v == "false" || v == "0" {
|
|
c.FollowSymlinks = false
|
|
}
|
|
|
|
if v := os.Getenv("MCP_RESPECT_GITIGNORE"); v == "false" || v == "0" {
|
|
c.RespectGitignore = false
|
|
}
|
|
}
|
|
|
|
// IsPathAllowed checks if a path is within the workspace root.
|
|
// It resolves symlinks to prevent path traversal attacks.
|
|
func (c *Config) IsPathAllowed(path string) bool {
|
|
// Get absolute path of the target
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Get absolute path of workspace root
|
|
absRoot, err := filepath.Abs(c.WorkspaceRoot)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Always try to resolve workspace root symlinks for consistent comparison
|
|
evalRoot, evalErr := filepath.EvalSymlinks(absRoot)
|
|
if evalErr == nil {
|
|
absRoot = evalRoot
|
|
}
|
|
|
|
// For the target path, try to resolve symlinks
|
|
evalPath, evalErr := filepath.EvalSymlinks(absPath)
|
|
if evalErr == nil {
|
|
// File exists and was resolved
|
|
absPath = evalPath
|
|
} else {
|
|
// File doesn't exist - resolve parent directories to match workspace root resolution
|
|
// Walk up the tree until we find an existing directory
|
|
dir := filepath.Dir(absPath)
|
|
remaining := filepath.Base(absPath)
|
|
|
|
for dir != "." && dir != "/" && dir != absPath {
|
|
evalDir, evalErr := filepath.EvalSymlinks(dir)
|
|
if evalErr == nil {
|
|
// Found an existing directory, reconstruct the path
|
|
absPath = filepath.Join(evalDir, remaining)
|
|
break
|
|
}
|
|
// Move up one level
|
|
newDir := filepath.Dir(dir)
|
|
if newDir == dir {
|
|
// Reached the root without finding an existing directory
|
|
break
|
|
}
|
|
remaining = filepath.Join(filepath.Base(dir), remaining)
|
|
dir = newDir
|
|
}
|
|
}
|
|
|
|
// Compute relative path
|
|
rel, err := filepath.Rel(absRoot, absPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
// Check if the path is within workspace (doesn't start with ..)
|
|
// This prevents both "../" attacks and symlink bypasses
|
|
// The workspace root itself (rel == ".") is a valid, allowed path
|
|
return !strings.HasPrefix(rel, "..")
|
|
}
|
|
|
|
// Validate validates the configuration and returns an error if invalid.
|
|
// Checks include:
|
|
// - MaxFileSize and MaxParseSize must be positive
|
|
// - LSPTimeout must be positive
|
|
// - WorkspaceRoot must exist (when not empty)
|
|
func (c *Config) Validate() error {
|
|
// Validate MaxFileSize
|
|
if c.MaxFileSize <= 0 {
|
|
return fmt.Errorf("max_file_size must be positive, got %d", c.MaxFileSize)
|
|
}
|
|
|
|
// Validate MaxParseSize
|
|
if c.MaxParseSize <= 0 {
|
|
return fmt.Errorf("max_parse_size must be positive, got %d", c.MaxParseSize)
|
|
}
|
|
|
|
// Validate LSPTimeout
|
|
if c.LSPTimeout <= 0 {
|
|
return fmt.Errorf("lsp_timeout must be positive, got %v", c.LSPTimeout)
|
|
}
|
|
|
|
// Validate WorkspaceRoot exists
|
|
if c.WorkspaceRoot != "" {
|
|
info, err := os.Stat(c.WorkspaceRoot)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("workspace_root does not exist: %s", c.WorkspaceRoot)
|
|
}
|
|
return fmt.Errorf("cannot access workspace_root: %w", err)
|
|
}
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("workspace_root is not a directory: %s", c.WorkspaceRoot)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|