# Directory Structure ``` ├── .idea │ ├── .gitignore │ ├── filesystem-server.iml │ ├── modules.xml │ └── vcs.xml ├── Dockerfile ├── go.mod ├── go.sum ├── main.go └── smithery.yaml ``` # Files -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | ``` -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- ``` 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <project version="4"> 3 | <component name="VcsDirectoryMappings" defaultProject="true" /> 4 | </project> ``` -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- ``` 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <project version="4"> 3 | <component name="ProjectModuleManager"> 4 | <modules> 5 | <module fileurl="file://$PROJECT_DIR$/.idea/filesystem-server.iml" filepath="$PROJECT_DIR$/.idea/filesystem-server.iml" /> 6 | </modules> 7 | </component> 8 | </project> ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM golang:1.23-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Copy go.mod and go.sum first for caching dependencies 7 | COPY go.mod go.sum ./ 8 | 9 | # Download dependencies 10 | RUN go mod download 11 | 12 | # Copy the source code 13 | COPY . . 14 | 15 | # Build the application 16 | RUN go build -ldflags="-s -w" -o server . 17 | 18 | FROM alpine:latest 19 | 20 | WORKDIR /app 21 | 22 | # Copy the built binary from the builder stage 23 | COPY --from=builder /app/server ./ 24 | 25 | # The container will by default pass '/app' as the allowed directory if no other command line arguments are provided 26 | ENTRYPOINT ["./server"] 27 | CMD ["/app"] 28 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - allowedDirectory 10 | properties: 11 | allowedDirectory: 12 | type: string 13 | description: The absolute path to an allowed directory for the filesystem 14 | server. For example, in the Docker container '/app' is a good default. 15 | additionalDirectories: 16 | type: array 17 | items: 18 | type: string 19 | description: Optional additional allowed directories. 20 | commandFunction: 21 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 22 | |- 23 | (config) => { const args = [config.allowedDirectory]; if (config.additionalDirectories && Array.isArray(config.additionalDirectories)) { args.push(...config.additionalDirectories); } return { command: './server', args: args }; } 24 | exampleConfig: 25 | allowedDirectory: /app 26 | additionalDirectories: [] 27 | ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | 11 | "github.com/mark3labs/mcp-go/mcp" 12 | "github.com/mark3labs/mcp-go/server" 13 | ) 14 | 15 | type FileInfo struct { 16 | Size int64 `json:"size"` 17 | Created time.Time `json:"created"` 18 | Modified time.Time `json:"modified"` 19 | Accessed time.Time `json:"accessed"` 20 | IsDirectory bool `json:"isDirectory"` 21 | IsFile bool `json:"isFile"` 22 | Permissions string `json:"permissions"` 23 | } 24 | 25 | type FilesystemServer struct { 26 | allowedDirs []string 27 | server *server.MCPServer 28 | } 29 | 30 | func NewFilesystemServer(allowedDirs []string) (*FilesystemServer, error) { 31 | // Normalize and validate directories 32 | normalized := make([]string, 0, len(allowedDirs)) 33 | for _, dir := range allowedDirs { 34 | abs, err := filepath.Abs(dir) 35 | if err != nil { 36 | return nil, fmt.Errorf("failed to resolve path %s: %w", dir, err) 37 | } 38 | 39 | info, err := os.Stat(abs) 40 | if err != nil { 41 | return nil, fmt.Errorf( 42 | "failed to access directory %s: %w", 43 | abs, 44 | err, 45 | ) 46 | } 47 | if !info.IsDir() { 48 | return nil, fmt.Errorf("path is not a directory: %s", abs) 49 | } 50 | 51 | normalized = append(normalized, filepath.Clean(strings.ToLower(abs))) 52 | } 53 | 54 | s := &FilesystemServer{ 55 | allowedDirs: normalized, 56 | server: server.NewMCPServer( 57 | "secure-filesystem-server", 58 | "0.2.0", 59 | server.WithToolCapabilities(true), 60 | ), 61 | } 62 | 63 | // Register tool handlers 64 | s.server.AddTool(mcp.Tool{ 65 | Name: "read_file", 66 | Description: "Read the complete contents of a file from the file system.", 67 | InputSchema: mcp.ToolInputSchema{ 68 | Type: "object", 69 | Properties: map[string]interface{}{ 70 | "path": map[string]interface{}{ 71 | "type": "string", 72 | "description": "Path to the file to read", 73 | }, 74 | }, 75 | }, 76 | }, s.handleReadFile) 77 | 78 | s.server.AddTool(mcp.Tool{ 79 | Name: "write_file", 80 | Description: "Create a new file or overwrite an existing file with new content.", 81 | InputSchema: mcp.ToolInputSchema{ 82 | Type: "object", 83 | Properties: map[string]interface{}{ 84 | "path": map[string]interface{}{ 85 | "type": "string", 86 | "description": "Path where to write the file", 87 | }, 88 | "content": map[string]interface{}{ 89 | "type": "string", 90 | "description": "Content to write to the file", 91 | }, 92 | }, 93 | }, 94 | }, s.handleWriteFile) 95 | 96 | s.server.AddTool(mcp.Tool{ 97 | Name: "list_directory", 98 | Description: "Get a detailed listing of all files and directories in a specified path.", 99 | InputSchema: mcp.ToolInputSchema{ 100 | Type: "object", 101 | Properties: map[string]interface{}{ 102 | "path": map[string]interface{}{ 103 | "type": "string", 104 | "description": "Path of the directory to list", 105 | }, 106 | }, 107 | }, 108 | }, s.handleListDirectory) 109 | 110 | s.server.AddTool(mcp.Tool{ 111 | Name: "create_directory", 112 | Description: "Create a new directory or ensure a directory exists.", 113 | InputSchema: mcp.ToolInputSchema{ 114 | Type: "object", 115 | Properties: map[string]interface{}{ 116 | "path": map[string]interface{}{ 117 | "type": "string", 118 | "description": "Path of the directory to create", 119 | }, 120 | }, 121 | }, 122 | }, s.handleCreateDirectory) 123 | 124 | s.server.AddTool(mcp.Tool{ 125 | Name: "move_file", 126 | Description: "Move or rename files and directories.", 127 | InputSchema: mcp.ToolInputSchema{ 128 | Type: "object", 129 | Properties: map[string]interface{}{ 130 | "source": map[string]interface{}{ 131 | "type": "string", 132 | "description": "Source path of the file or directory", 133 | }, 134 | "destination": map[string]interface{}{ 135 | "type": "string", 136 | "description": "Destination path", 137 | }, 138 | }, 139 | }, 140 | }, s.handleMoveFile) 141 | 142 | s.server.AddTool(mcp.Tool{ 143 | Name: "search_files", 144 | Description: "Recursively search for files and directories matching a pattern.", 145 | InputSchema: mcp.ToolInputSchema{ 146 | Type: "object", 147 | Properties: map[string]interface{}{ 148 | "path": map[string]interface{}{ 149 | "type": "string", 150 | "description": "Starting path for the search", 151 | }, 152 | "pattern": map[string]interface{}{ 153 | "type": "string", 154 | "description": "Search pattern to match against file names", 155 | }, 156 | }, 157 | }, 158 | }, s.handleSearchFiles) 159 | 160 | s.server.AddTool(mcp.Tool{ 161 | Name: "get_file_info", 162 | Description: "Retrieve detailed metadata about a file or directory.", 163 | InputSchema: mcp.ToolInputSchema{ 164 | Type: "object", 165 | Properties: map[string]interface{}{ 166 | "path": map[string]interface{}{ 167 | "type": "string", 168 | "description": "Path to the file or directory", 169 | }, 170 | }, 171 | }, 172 | }, s.handleGetFileInfo) 173 | 174 | s.server.AddTool(mcp.Tool{ 175 | Name: "list_allowed_directories", 176 | Description: "Returns the list of directories that this server is allowed to access.", 177 | InputSchema: mcp.ToolInputSchema{ 178 | Type: "object", 179 | Properties: map[string]interface{}{}, 180 | }, 181 | }, s.handleListAllowedDirectories) 182 | 183 | return s, nil 184 | } 185 | 186 | func (s *FilesystemServer) validatePath(requestedPath string) (string, error) { 187 | abs, err := filepath.Abs(requestedPath) 188 | if err != nil { 189 | return "", fmt.Errorf("invalid path: %w", err) 190 | } 191 | 192 | normalized := filepath.Clean(strings.ToLower(abs)) 193 | 194 | // Check if path is within allowed directories 195 | allowed := false 196 | for _, dir := range s.allowedDirs { 197 | if strings.HasPrefix(normalized, dir) { 198 | allowed = true 199 | break 200 | } 201 | } 202 | if !allowed { 203 | return "", fmt.Errorf( 204 | "access denied - path outside allowed directories: %s", 205 | abs, 206 | ) 207 | } 208 | 209 | // Handle symlinks 210 | realPath, err := filepath.EvalSymlinks(abs) 211 | if err != nil { 212 | if !os.IsNotExist(err) { 213 | return "", err 214 | } 215 | // For new files, check parent directory 216 | parent := filepath.Dir(abs) 217 | realParent, err := filepath.EvalSymlinks(parent) 218 | if err != nil { 219 | return "", fmt.Errorf("parent directory does not exist: %s", parent) 220 | } 221 | normalizedParent := filepath.Clean(strings.ToLower(realParent)) 222 | for _, dir := range s.allowedDirs { 223 | if strings.HasPrefix(normalizedParent, dir) { 224 | return abs, nil 225 | } 226 | } 227 | return "", fmt.Errorf( 228 | "access denied - parent directory outside allowed directories", 229 | ) 230 | } 231 | 232 | normalizedReal := filepath.Clean(strings.ToLower(realPath)) 233 | for _, dir := range s.allowedDirs { 234 | if strings.HasPrefix(normalizedReal, dir) { 235 | return realPath, nil 236 | } 237 | } 238 | return "", fmt.Errorf( 239 | "access denied - symlink target outside allowed directories", 240 | ) 241 | } 242 | 243 | func (s *FilesystemServer) getFileStats(path string) (FileInfo, error) { 244 | info, err := os.Stat(path) 245 | if err != nil { 246 | return FileInfo{}, err 247 | } 248 | 249 | return FileInfo{ 250 | Size: info.Size(), 251 | Created: info.ModTime(), // Note: ModTime used as birth time isn't always available 252 | Modified: info.ModTime(), 253 | Accessed: info.ModTime(), // Note: Access time isn't always available 254 | IsDirectory: info.IsDir(), 255 | IsFile: !info.IsDir(), 256 | Permissions: fmt.Sprintf("%o", info.Mode().Perm()), 257 | }, nil 258 | } 259 | 260 | func (s *FilesystemServer) searchFiles( 261 | rootPath, pattern string, 262 | ) ([]string, error) { 263 | var results []string 264 | pattern = strings.ToLower(pattern) 265 | 266 | err := filepath.Walk( 267 | rootPath, 268 | func(path string, info os.FileInfo, err error) error { 269 | if err != nil { 270 | return nil // Skip errors and continue 271 | } 272 | 273 | // Try to validate path 274 | if _, err := s.validatePath(path); err != nil { 275 | return nil // Skip invalid paths 276 | } 277 | 278 | if strings.Contains(strings.ToLower(info.Name()), pattern) { 279 | results = append(results, path) 280 | } 281 | return nil 282 | }, 283 | ) 284 | if err != nil { 285 | return nil, err 286 | } 287 | return results, nil 288 | } 289 | 290 | // Tool handlers 291 | 292 | func (s *FilesystemServer) handleReadFile( 293 | arguments map[string]interface{}, 294 | ) (*mcp.CallToolResult, error) { 295 | path, ok := arguments["path"].(string) 296 | if !ok { 297 | return nil, fmt.Errorf("path must be a string") 298 | } 299 | 300 | validPath, err := s.validatePath(path) 301 | if err != nil { 302 | return nil, err 303 | } 304 | 305 | content, err := os.ReadFile(validPath) 306 | if err != nil { 307 | return &mcp.CallToolResult{ 308 | Content: []interface{}{ 309 | mcp.TextContent{ 310 | Type: "text", 311 | Text: fmt.Sprintf("Error reading file: %v", err), 312 | }, 313 | }, 314 | IsError: true, 315 | }, nil 316 | } 317 | 318 | return &mcp.CallToolResult{ 319 | Content: []interface{}{ 320 | mcp.TextContent{ 321 | Type: "text", 322 | Text: string(content), 323 | }, 324 | }, 325 | }, nil 326 | } 327 | 328 | func (s *FilesystemServer) handleWriteFile( 329 | arguments map[string]interface{}, 330 | ) (*mcp.CallToolResult, error) { 331 | path, ok := arguments["path"].(string) 332 | if !ok { 333 | return nil, fmt.Errorf("path must be a string") 334 | } 335 | content, ok := arguments["content"].(string) 336 | if !ok { 337 | return nil, fmt.Errorf("content must be a string") 338 | } 339 | 340 | validPath, err := s.validatePath(path) 341 | if err != nil { 342 | return nil, err 343 | } 344 | 345 | if err := os.WriteFile(validPath, []byte(content), 0644); err != nil { 346 | return &mcp.CallToolResult{ 347 | Content: []interface{}{ 348 | mcp.TextContent{ 349 | Type: "text", 350 | Text: fmt.Sprintf("Error writing file: %v", err), 351 | }, 352 | }, 353 | IsError: true, 354 | }, nil 355 | } 356 | 357 | return &mcp.CallToolResult{ 358 | Content: []interface{}{ 359 | mcp.TextContent{ 360 | Type: "text", 361 | Text: fmt.Sprintf("Successfully wrote to %s", path), 362 | }, 363 | }, 364 | }, nil 365 | } 366 | 367 | func (s *FilesystemServer) handleListDirectory( 368 | arguments map[string]interface{}, 369 | ) (*mcp.CallToolResult, error) { 370 | path, ok := arguments["path"].(string) 371 | if !ok { 372 | return nil, fmt.Errorf("path must be a string") 373 | } 374 | 375 | validPath, err := s.validatePath(path) 376 | if err != nil { 377 | return nil, err 378 | } 379 | 380 | entries, err := os.ReadDir(validPath) 381 | if err != nil { 382 | return &mcp.CallToolResult{ 383 | Content: []interface{}{ 384 | mcp.TextContent{ 385 | Type: "text", 386 | Text: fmt.Sprintf("Error reading directory: %v", err), 387 | }, 388 | }, 389 | IsError: true, 390 | }, nil 391 | } 392 | 393 | var result strings.Builder 394 | for _, entry := range entries { 395 | prefix := "[FILE]" 396 | if entry.IsDir() { 397 | prefix = "[DIR]" 398 | } 399 | fmt.Fprintf(&result, "%s %s\n", prefix, entry.Name()) 400 | } 401 | 402 | return &mcp.CallToolResult{ 403 | Content: []interface{}{ 404 | mcp.TextContent{ 405 | Type: "text", 406 | Text: result.String(), 407 | }, 408 | }, 409 | }, nil 410 | } 411 | 412 | func (s *FilesystemServer) handleCreateDirectory( 413 | arguments map[string]interface{}, 414 | ) (*mcp.CallToolResult, error) { 415 | path, ok := arguments["path"].(string) 416 | if !ok { 417 | return nil, fmt.Errorf("path must be a string") 418 | } 419 | 420 | validPath, err := s.validatePath(path) 421 | if err != nil { 422 | return nil, err 423 | } 424 | 425 | if err := os.MkdirAll(validPath, 0755); err != nil { 426 | return &mcp.CallToolResult{ 427 | Content: []interface{}{ 428 | mcp.TextContent{ 429 | Type: "text", 430 | Text: fmt.Sprintf("Error creating directory: %v", err), 431 | }, 432 | }, 433 | IsError: true, 434 | }, nil 435 | } 436 | 437 | return &mcp.CallToolResult{ 438 | Content: []interface{}{ 439 | mcp.TextContent{ 440 | Type: "text", 441 | Text: fmt.Sprintf("Successfully created directory %s", path), 442 | }, 443 | }, 444 | }, nil 445 | } 446 | 447 | func (s *FilesystemServer) handleMoveFile( 448 | arguments map[string]interface{}, 449 | ) (*mcp.CallToolResult, error) { 450 | source, ok := arguments["source"].(string) 451 | if !ok { 452 | return nil, fmt.Errorf("source must be a string") 453 | } 454 | destination, ok := arguments["destination"].(string) 455 | if !ok { 456 | return nil, fmt.Errorf("destination must be a string") 457 | } 458 | 459 | validSource, err := s.validatePath(source) 460 | if err != nil { 461 | return nil, err 462 | } 463 | validDest, err := s.validatePath(destination) 464 | if err != nil { 465 | return nil, err 466 | } 467 | 468 | if err := os.Rename(validSource, validDest); err != nil { 469 | return &mcp.CallToolResult{ 470 | Content: []interface{}{ 471 | mcp.TextContent{ 472 | Type: "text", 473 | Text: fmt.Sprintf("Error moving file: %v", err), 474 | }, 475 | }, 476 | IsError: true, 477 | }, nil 478 | } 479 | 480 | return &mcp.CallToolResult{ 481 | Content: []interface{}{ 482 | mcp.TextContent{ 483 | Type: "text", 484 | Text: fmt.Sprintf( 485 | "Successfully moved %s to %s", 486 | source, 487 | destination, 488 | ), 489 | }, 490 | }, 491 | }, nil 492 | } 493 | 494 | func (s *FilesystemServer) handleSearchFiles( 495 | arguments map[string]interface{}, 496 | ) (*mcp.CallToolResult, error) { 497 | path, ok := arguments["path"].(string) 498 | if !ok { 499 | return nil, fmt.Errorf("path must be a string") 500 | } 501 | pattern, ok := arguments["pattern"].(string) 502 | if !ok { 503 | return nil, fmt.Errorf("pattern must be a string") 504 | } 505 | 506 | validPath, err := s.validatePath(path) 507 | if err != nil { 508 | return nil, err 509 | } 510 | 511 | results, err := s.searchFiles(validPath, pattern) 512 | if err != nil { 513 | return &mcp.CallToolResult{ 514 | Content: []interface{}{ 515 | mcp.TextContent{ 516 | Type: "text", 517 | Text: fmt.Sprintf("Error searching files: %v", 518 | err), 519 | }, 520 | }, 521 | IsError: true, 522 | }, nil 523 | } 524 | 525 | return &mcp.CallToolResult{ 526 | Content: []interface{}{ 527 | mcp.TextContent{ 528 | Type: "text", 529 | Text: strings.Join(results, "\n"), 530 | }, 531 | }, 532 | }, nil 533 | } 534 | 535 | func (s *FilesystemServer) handleGetFileInfo( 536 | arguments map[string]interface{}, 537 | ) (*mcp.CallToolResult, error) { 538 | path, ok := arguments["path"].(string) 539 | if !ok { 540 | return nil, fmt.Errorf("path must be a string") 541 | } 542 | 543 | validPath, err := s.validatePath(path) 544 | if err != nil { 545 | return nil, err 546 | } 547 | 548 | info, err := s.getFileStats(validPath) 549 | if err != nil { 550 | return &mcp.CallToolResult{ 551 | Content: []interface{}{ 552 | mcp.TextContent{ 553 | Type: "text", 554 | Text: fmt.Sprintf("Error getting file info: %v", err), 555 | }, 556 | }, 557 | IsError: true, 558 | }, nil 559 | } 560 | 561 | return &mcp.CallToolResult{ 562 | Content: []interface{}{ 563 | mcp.TextContent{ 564 | Type: "text", 565 | Text: fmt.Sprintf( 566 | "Size: %d\nCreated: %s\nModified: %s\nAccessed: %s\nIsDirectory: %v\nIsFile: %v\nPermissions: %s", 567 | info.Size, 568 | info.Created.Format(time.RFC3339), 569 | info.Modified.Format(time.RFC3339), 570 | info.Accessed.Format(time.RFC3339), 571 | info.IsDirectory, 572 | info.IsFile, 573 | info.Permissions, 574 | ), 575 | }, 576 | }, 577 | }, nil 578 | } 579 | 580 | func (s *FilesystemServer) handleListAllowedDirectories( 581 | arguments map[string]interface{}, 582 | ) (*mcp.CallToolResult, error) { 583 | return &mcp.CallToolResult{ 584 | Content: []interface{}{ 585 | mcp.TextContent{ 586 | Type: "text", 587 | Text: fmt.Sprintf( 588 | "Allowed directories:\n%s", 589 | strings.Join(s.allowedDirs, "\n"), 590 | ), 591 | }, 592 | }, 593 | }, nil 594 | } 595 | 596 | func (s *FilesystemServer) Serve() error { 597 | return server.ServeStdio(s.server) 598 | } 599 | 600 | func main() { 601 | // Parse command line arguments 602 | if len(os.Args) < 2 { 603 | fmt.Fprintf( 604 | os.Stderr, 605 | "Usage: %s <allowed-directory> [additional-directories...]\n", 606 | os.Args[0], 607 | ) 608 | os.Exit(1) 609 | } 610 | 611 | // Create and start the server 612 | fs, err := NewFilesystemServer(os.Args[1:]) 613 | if err != nil { 614 | log.Fatalf("Failed to create server: %v", err) 615 | } 616 | 617 | // Serve requests 618 | if err := fs.Serve(); err != nil { 619 | log.Fatalf("Server error: %v", err) 620 | } 621 | } 622 | ```