Skip to content

Commit 5aaac80

Browse files
author
Dimitar Grigorov
committed
Add config externalization and tool title annotations
1 parent 93158d5 commit 5aaac80

File tree

7 files changed

+221
-21
lines changed

7 files changed

+221
-21
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,29 @@ Once installed, just ask Claude:
117117
- **Automatic:** Claude Desktop/Code provide workspace directories automatically
118118
- **Manual:** Specify directories in config `args: ["/path/to/project"]`
119119

120+
## Configuration
121+
122+
The server can be configured via environment variables:
123+
124+
| Variable | Description | Default |
125+
|----------|-------------|---------|
126+
| `MCP_DEFAULT_ENCODING` | Default encoding for `write_file` when none specified | `cp1251` |
127+
| `MCP_MAX_FILE_SIZE` | Maximum file size in bytes for operations | `10485760` (10MB) |
128+
129+
**Example (Claude Desktop config):**
130+
```json
131+
{
132+
"mcpServers": {
133+
"file-tools": {
134+
"command": "/path/to/mcp-file-tools",
135+
"env": {
136+
"MCP_DEFAULT_ENCODING": "utf-8"
137+
}
138+
}
139+
}
140+
}
141+
```
142+
120143
## Use Cases
121144

122145
### Legacy Codebases

cmd/mcp-file-tools/main.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ func main() {
3333

3434
// Create MCP server with allowed directories (can be empty, directories can be added dynamically)
3535
// Pass nil for logger to disable logging middleware (recovery still active)
36-
server := filetoolsserver.NewServer(normalized, nil)
36+
// Pass nil for config to load from environment variables (MCP_DEFAULT_ENCODING, MCP_MAX_FILE_SIZE)
37+
server := filetoolsserver.NewServer(normalized, nil, nil)
3738

3839
// Run server on stdio transport
3940
ctx := context.Background()

filetoolsserver/handler/handler.go

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,47 @@ package handler
33
import (
44
"sync"
55

6+
"github.com/dimitar-grigorov/mcp-file-tools/internal/config"
67
"github.com/dimitar-grigorov/mcp-file-tools/internal/security"
78
)
89

9-
const (
10-
// DefaultEncoding is the default encoding used when none is specified
11-
DefaultEncoding = "cp1251"
12-
)
13-
1410
// Handler handles all file tool operations
1511
type Handler struct {
16-
defaultEncoding string
17-
allowedDirs []string
18-
mu sync.RWMutex
12+
config *config.Config
13+
allowedDirs []string
14+
mu sync.RWMutex
15+
}
16+
17+
// Option is a functional option for configuring Handler
18+
type Option func(*Handler)
19+
20+
// WithConfig sets the configuration for the handler
21+
func WithConfig(cfg *config.Config) Option {
22+
return func(h *Handler) {
23+
if cfg != nil {
24+
h.config = cfg
25+
}
26+
}
1927
}
2028

21-
// NewHandler creates a new Handler with allowed directories
22-
func NewHandler(allowedDirs []string) *Handler {
23-
return &Handler{
24-
defaultEncoding: DefaultEncoding,
25-
allowedDirs: allowedDirs,
29+
// NewHandler creates a new Handler with allowed directories and optional configuration.
30+
// If no config is provided via WithConfig, default configuration is used.
31+
func NewHandler(allowedDirs []string, opts ...Option) *Handler {
32+
h := &Handler{
33+
config: config.Load(), // Load defaults from environment
34+
allowedDirs: allowedDirs,
2635
}
36+
37+
for _, opt := range opts {
38+
opt(h)
39+
}
40+
41+
return h
42+
}
43+
44+
// GetConfig returns the handler's configuration
45+
func (h *Handler) GetConfig() *config.Config {
46+
return h.config
2747
}
2848

2949
// GetAllowedDirectories returns a copy of the allowed directories

filetoolsserver/handler/write.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ func (h *Handler) HandleWriteFile(ctx context.Context, req *mcp.CallToolRequest,
1919
}
2020
validatedPath := v.Path
2121

22-
// Default encoding
23-
encodingName := h.defaultEncoding
22+
// Default encoding from config
23+
encodingName := h.config.DefaultEncoding
2424
if input.Encoding != "" {
2525
encodingName = strings.ToLower(input.Encoding)
2626
}

filetoolsserver/server.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"log/slog"
55

66
"github.com/dimitar-grigorov/mcp-file-tools/filetoolsserver/handler"
7+
"github.com/dimitar-grigorov/mcp-file-tools/internal/config"
78
"github.com/modelcontextprotocol/go-sdk/mcp"
89
)
910

@@ -28,21 +29,26 @@ func boolPtr(b bool) *bool {
2829

2930
// NewServer creates a new MCP server with all file tools registered.
3031
// If logger is nil, logging middleware is disabled but recovery is still active.
31-
func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
32-
h := handler.NewHandler(allowedDirs)
32+
// If cfg is nil, configuration is loaded from environment variables.
33+
func NewServer(allowedDirs []string, logger *slog.Logger, cfg *config.Config) *mcp.Server {
34+
var handlerOpts []handler.Option
35+
if cfg != nil {
36+
handlerOpts = append(handlerOpts, handler.WithConfig(cfg))
37+
}
38+
h := handler.NewHandler(allowedDirs, handlerOpts...)
3339

3440
impl := &mcp.Implementation{
3541
Name: "mcp-file-tools",
3642
Version: Version,
3743
}
3844

39-
opts := &mcp.ServerOptions{
45+
serverOpts := &mcp.ServerOptions{
4046
Instructions: serverInstructions,
4147
Logger: logger,
4248
InitializedHandler: createInitializedHandler(h),
4349
RootsListChangedHandler: createRootsListChangedHandler(h),
4450
}
45-
server := mcp.NewServer(impl, opts)
51+
server := mcp.NewServer(impl, serverOpts)
4652

4753
// Register all tools using the new AddTool API with annotations
4854
// All handlers are wrapped with recovery middleware (and logging if logger is provided)
@@ -52,6 +58,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
5258
Name: "read_text_file",
5359
Description: "Read files with automatic encoding detection and conversion to UTF-8. USE THIS instead of built-in Read tool when files may contain non-UTF-8 encodings. Auto-detects encoding if not specified. Parameters: path (required), encoding (optional), head (optional), tail (optional).",
5460
Annotations: &mcp.ToolAnnotations{
61+
Title: "Read Text File",
5562
ReadOnlyHint: true,
5663
OpenWorldHint: boolPtr(false),
5764
},
@@ -61,6 +68,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
6168
Name: "list_directory",
6269
Description: "List files and directories with optional glob pattern filtering (e.g., *.pas, *.dfm). Parameters: path (required), pattern (optional, default: *).",
6370
Annotations: &mcp.ToolAnnotations{
71+
Title: "List Directory",
6472
ReadOnlyHint: true,
6573
OpenWorldHint: boolPtr(false),
6674
},
@@ -70,6 +78,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
7078
Name: "list_encodings",
7179
Description: "List all supported file encodings (UTF-8, CP1251, CP1252, KOI8-R, ISO-8859-x, and others). Returns name, aliases, and description for each.",
7280
Annotations: &mcp.ToolAnnotations{
81+
Title: "List Encodings",
7382
ReadOnlyHint: true,
7483
OpenWorldHint: boolPtr(false),
7584
},
@@ -79,6 +88,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
7988
Name: "detect_encoding",
8089
Description: "Auto-detect file encoding with confidence score and BOM detection. ALWAYS use this first when you encounter � characters or unknown encoding. Returns encoding name, confidence percentage (0-100), and whether file has BOM. Parameter: path (required).",
8190
Annotations: &mcp.ToolAnnotations{
91+
Title: "Detect Encoding",
8292
ReadOnlyHint: true,
8393
OpenWorldHint: boolPtr(false),
8494
},
@@ -88,6 +98,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
8898
Name: "list_allowed_directories",
8999
Description: "Returns the list of directories this server is allowed to access. Subdirectories are also accessible. If empty, user needs to add directory paths as args in .mcp.json.",
90100
Annotations: &mcp.ToolAnnotations{
101+
Title: "List Allowed Directories",
91102
ReadOnlyHint: true,
92103
OpenWorldHint: boolPtr(false),
93104
},
@@ -97,6 +108,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
97108
Name: "get_file_info",
98109
Description: "Retrieve detailed metadata about a file or directory. Returns size, creation time, last modified time, last accessed time, permissions, and type (file/directory). Only works within allowed directories. Parameter: path (required).",
99110
Annotations: &mcp.ToolAnnotations{
111+
Title: "Get File Info",
100112
ReadOnlyHint: true,
101113
OpenWorldHint: boolPtr(false),
102114
},
@@ -106,6 +118,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
106118
Name: "directory_tree",
107119
Description: "Get a recursive tree view of files and directories as a JSON structure. Each entry includes 'name', 'type' (file/directory), and 'children' for directories. Files have no children array, while directories always have a children array (which may be empty). The output is formatted with 2-space indentation for readability. Only works within allowed directories. Parameters: path (required), excludePatterns (optional array of glob patterns to exclude).",
108120
Annotations: &mcp.ToolAnnotations{
121+
Title: "Directory Tree",
109122
ReadOnlyHint: true,
110123
OpenWorldHint: boolPtr(false),
111124
},
@@ -115,6 +128,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
115128
Name: "search_files",
116129
Description: "Recursively search for files and directories matching a glob pattern. Use '*.ext' to match in current directory, '**/*.ext' to match recursively in all subdirectories. Returns full paths to matching items. Parameters: path (required), pattern (required), excludePatterns (optional array of patterns to skip).",
117130
Annotations: &mcp.ToolAnnotations{
131+
Title: "Search Files",
118132
ReadOnlyHint: true,
119133
OpenWorldHint: boolPtr(false),
120134
},
@@ -125,6 +139,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
125139
Name: "create_directory",
126140
Description: "Create a directory recursively (mkdir -p). Succeeds silently if already exists. Parameter: path (required).",
127141
Annotations: &mcp.ToolAnnotations{
142+
Title: "Create Directory",
128143
ReadOnlyHint: false,
129144
IdempotentHint: true,
130145
DestructiveHint: boolPtr(false),
@@ -134,8 +149,9 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
134149

135150
mcp.AddTool(server, &mcp.Tool{
136151
Name: "write_file",
137-
Description: "Write files with encoding conversion from UTF-8. USE THIS instead of built-in Write tool when writing to non-UTF-8 files (legacy codebases, Cyrillic text). Default encoding is cp1251 for backward compatibility. Parameters: path (required), content (required), encoding (cp1251/windows-1251/utf-8, default: cp1251).",
152+
Description: "Write files with encoding conversion from UTF-8. USE THIS instead of built-in Write tool when writing to non-UTF-8 files (legacy codebases, Cyrillic text). Default encoding is cp1251 (configurable via MCP_DEFAULT_ENCODING). Parameters: path (required), content (required), encoding (cp1251/windows-1251/utf-8, default: cp1251).",
138153
Annotations: &mcp.ToolAnnotations{
154+
Title: "Write File",
139155
ReadOnlyHint: false,
140156
IdempotentHint: true,
141157
DestructiveHint: boolPtr(true),
@@ -147,6 +163,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
147163
Name: "move_file",
148164
Description: "Move or rename files and directories. Can move files between directories and rename them in a single operation. Fails if destination already exists. Works for both files and directories. Parameters: source (required), destination (required).",
149165
Annotations: &mcp.ToolAnnotations{
166+
Title: "Move File",
150167
ReadOnlyHint: false,
151168
IdempotentHint: false,
152169
DestructiveHint: boolPtr(false),
@@ -158,6 +175,7 @@ func NewServer(allowedDirs []string, logger *slog.Logger) *mcp.Server {
158175
Name: "edit_file",
159176
Description: "Make line-based edits to a text file. Each edit replaces exact text sequences with new content. Supports whitespace-flexible matching when exact match fails. Returns a git-style unified diff showing the changes. Parameters: path (required), edits (required array of {oldText, newText}), dryRun (optional bool, default false - if true, returns diff without writing).",
160177
Annotations: &mcp.ToolAnnotations{
178+
Title: "Edit File",
161179
ReadOnlyHint: false,
162180
IdempotentHint: false,
163181
DestructiveHint: boolPtr(true),

internal/config/config.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Package config provides configuration management for MCP file tools server.
2+
package config
3+
4+
import (
5+
"os"
6+
"strconv"
7+
8+
"github.com/dimitar-grigorov/mcp-file-tools/internal/encoding"
9+
)
10+
11+
const (
12+
// Environment variable names
13+
EnvDefaultEncoding = "MCP_DEFAULT_ENCODING"
14+
EnvMaxFileSize = "MCP_MAX_FILE_SIZE"
15+
16+
// Default values
17+
DefaultEncoding = "cp1251"
18+
DefaultMaxSize = int64(10 * 1024 * 1024) // 10MB
19+
)
20+
21+
// Config holds server configuration loaded from environment variables.
22+
type Config struct {
23+
// DefaultEncoding is the default encoding for write_file when none is specified.
24+
// Set via MCP_DEFAULT_ENCODING environment variable.
25+
// Default: "cp1251" (for backward compatibility with legacy codebases)
26+
DefaultEncoding string
27+
28+
// MaxFileSize is the maximum file size in bytes for read/write operations.
29+
// Set via MCP_MAX_FILE_SIZE environment variable.
30+
// Default: 10485760 (10MB)
31+
MaxFileSize int64
32+
}
33+
34+
// Load reads configuration from environment variables with sensible defaults.
35+
func Load() *Config {
36+
cfg := &Config{
37+
DefaultEncoding: DefaultEncoding,
38+
MaxFileSize: DefaultMaxSize,
39+
}
40+
41+
// Load default encoding from environment
42+
if enc := os.Getenv(EnvDefaultEncoding); enc != "" {
43+
// Validate encoding exists
44+
if _, ok := encoding.Get(enc); ok {
45+
cfg.DefaultEncoding = enc
46+
}
47+
// If invalid encoding, silently use default (cp1251)
48+
}
49+
50+
// Load max file size from environment
51+
if sizeStr := os.Getenv(EnvMaxFileSize); sizeStr != "" {
52+
if size, err := strconv.ParseInt(sizeStr, 10, 64); err == nil && size > 0 {
53+
cfg.MaxFileSize = size
54+
}
55+
}
56+
57+
return cfg
58+
}

internal/config/config_test.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"testing"
6+
)
7+
8+
func TestLoad_Defaults(t *testing.T) {
9+
// Clear any environment variables
10+
os.Unsetenv(EnvDefaultEncoding)
11+
os.Unsetenv(EnvMaxFileSize)
12+
13+
cfg := Load()
14+
15+
if cfg.DefaultEncoding != DefaultEncoding {
16+
t.Errorf("expected default encoding %q, got %q", DefaultEncoding, cfg.DefaultEncoding)
17+
}
18+
19+
if cfg.MaxFileSize != DefaultMaxSize {
20+
t.Errorf("expected default max size %d, got %d", DefaultMaxSize, cfg.MaxFileSize)
21+
}
22+
}
23+
24+
func TestLoad_CustomEncoding(t *testing.T) {
25+
os.Setenv(EnvDefaultEncoding, "utf-8")
26+
defer os.Unsetenv(EnvDefaultEncoding)
27+
28+
cfg := Load()
29+
30+
if cfg.DefaultEncoding != "utf-8" {
31+
t.Errorf("expected encoding utf-8, got %q", cfg.DefaultEncoding)
32+
}
33+
}
34+
35+
func TestLoad_InvalidEncoding(t *testing.T) {
36+
os.Setenv(EnvDefaultEncoding, "invalid-encoding-xyz")
37+
defer os.Unsetenv(EnvDefaultEncoding)
38+
39+
cfg := Load()
40+
41+
// Should fall back to default when invalid
42+
if cfg.DefaultEncoding != DefaultEncoding {
43+
t.Errorf("expected fallback to %q for invalid encoding, got %q", DefaultEncoding, cfg.DefaultEncoding)
44+
}
45+
}
46+
47+
func TestLoad_CustomMaxFileSize(t *testing.T) {
48+
os.Setenv(EnvMaxFileSize, "5242880")
49+
defer os.Unsetenv(EnvMaxFileSize)
50+
51+
cfg := Load()
52+
53+
if cfg.MaxFileSize != 5242880 {
54+
t.Errorf("expected max size 5242880, got %d", cfg.MaxFileSize)
55+
}
56+
}
57+
58+
func TestLoad_InvalidMaxFileSize(t *testing.T) {
59+
os.Setenv(EnvMaxFileSize, "not-a-number")
60+
defer os.Unsetenv(EnvMaxFileSize)
61+
62+
cfg := Load()
63+
64+
// Should fall back to default when invalid
65+
if cfg.MaxFileSize != DefaultMaxSize {
66+
t.Errorf("expected fallback to %d for invalid size, got %d", DefaultMaxSize, cfg.MaxFileSize)
67+
}
68+
}
69+
70+
func TestLoad_NegativeMaxFileSize(t *testing.T) {
71+
os.Setenv(EnvMaxFileSize, "-1000")
72+
defer os.Unsetenv(EnvMaxFileSize)
73+
74+
cfg := Load()
75+
76+
// Should fall back to default when negative
77+
if cfg.MaxFileSize != DefaultMaxSize {
78+
t.Errorf("expected fallback to %d for negative size, got %d", DefaultMaxSize, cfg.MaxFileSize)
79+
}
80+
}

0 commit comments

Comments
 (0)