# Directory Structure ``` ├── .github │ ├── dependabot.yml │ └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yml ├── Dockerfile ├── filesystemserver │ ├── handler │ │ ├── copy_file_test.go │ │ ├── copy_file.go │ │ ├── create_directory_test.go │ │ ├── create_directory.go │ │ ├── delete_file_test.go │ │ ├── delete_file.go │ │ ├── get_file_info_test.go │ │ ├── get_file_info.go │ │ ├── handler_test.go │ │ ├── handler.go │ │ ├── helper.go │ │ ├── list_allowed_directories_test.go │ │ ├── list_allowed_directories.go │ │ ├── list_directory_test.go │ │ ├── list_directory.go │ │ ├── modify_file.go │ │ ├── move_file.go │ │ ├── read_file_test.go │ │ ├── read_file.go │ │ ├── read_multiple_files_test.go │ │ ├── read_multiple_files.go │ │ ├── resources.go │ │ ├── search_files_test.go │ │ ├── search_files.go │ │ ├── search_within_files.go │ │ ├── tree_test.go │ │ ├── tree.go │ │ ├── types.go │ │ └── write_file.go │ ├── inprocess_test.go │ ├── server_test.go │ ├── server.go │ └── utils_test.go ├── go.mod ├── go.sum ├── LICENSE ├── main.go ├── README.md └── smithery.yaml ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` .aider* .opencode* OpenCode.md dist/ CLAUDE.md .claude/ # go build artifact mcp-filesystem-server ``` -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- ```yaml version: 2 before: hooks: - go mod tidy builds: - id: mcp-filesystem-server env: - CGO_ENABLED=0 goos: - linux - windows - darwin goarch: - amd64 - arm64 ldflags: - -s -w -X github.com/mark3labs/mcp-filesystem-server/filesystemserver.Version={{.Version}} binary: mcp-filesystem-server main: . archives: - id: default format_overrides: - goos: windows formats: - zip name_template: >- {{ .ProjectName }}_ {{- .Os }}_ {{- .Arch }} files: - README.md - LICENSE* checksum: name_template: 'checksums.txt' algorithm: sha256 # Using new snapshot configuration snapshot: version_template: "{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}" changelog: sort: asc filters: exclude: - '^docs:' - '^test:' - Merge pull request - Merge branch release: github: owner: mark3labs name: mcp-filesystem-server draft: false prerelease: auto name_template: "{{ .Tag }}" mode: replace ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # MCP Filesystem Server This MCP server provides secure access to the local filesystem via the Model Context Protocol (MCP). ## Components ### Resources - **file://** - Name: File System - Description: Access to files and directories on the local file system ### Tools #### File Operations - **read_file** - Read the complete contents of a file from the file system - Parameters: `path` (required): Path to the file to read - **read_multiple_files** - Read the contents of multiple files in a single operation - Parameters: `paths` (required): List of file paths to read - **write_file** - Create a new file or overwrite an existing file with new content - Parameters: `path` (required): Path where to write the file, `content` (required): Content to write to the file - **copy_file** - Copy files and directories - Parameters: `source` (required): Source path of the file or directory, `destination` (required): Destination path - **move_file** - Move or rename files and directories - Parameters: `source` (required): Source path of the file or directory, `destination` (required): Destination path - **delete_file** - Delete a file or directory from the file system - Parameters: `path` (required): Path to the file or directory to delete, `recursive` (optional): Whether to recursively delete directories (default: false) - **modify_file** - Update file by finding and replacing text using string matching or regex - Parameters: `path` (required): Path to the file to modify, `find` (required): Text to search for, `replace` (required): Text to replace with, `all_occurrences` (optional): Replace all occurrences (default: true), `regex` (optional): Treat find pattern as regex (default: false) #### Directory Operations - **list_directory** - Get a detailed listing of all files and directories in a specified path - Parameters: `path` (required): Path of the directory to list - **create_directory** - Create a new directory or ensure a directory exists - Parameters: `path` (required): Path of the directory to create - **tree** - Returns a hierarchical JSON representation of a directory structure - Parameters: `path` (required): Path of the directory to traverse, `depth` (optional): Maximum depth to traverse (default: 3), `follow_symlinks` (optional): Whether to follow symbolic links (default: false) #### Search and Information - **search_files** - Recursively search for files and directories matching a pattern - Parameters: `path` (required): Starting path for the search, `pattern` (required): Search pattern to match against file names - **search_within_files** - Search for text within file contents across directory trees - Parameters: `path` (required): Starting directory for the search, `substring` (required): Text to search for within file contents, `depth` (optional): Maximum directory depth to search, `max_results` (optional): Maximum number of results to return (default: 1000) - **get_file_info** - Retrieve detailed metadata about a file or directory - Parameters: `path` (required): Path to the file or directory - **list_allowed_directories** - Returns the list of directories that this server is allowed to access - Parameters: None ## Features - Secure access to specified directories - Path validation to prevent directory traversal attacks - Symlink resolution with security checks - MIME type detection - Support for text, binary, and image files - Size limits for inline content and base64 encoding ## Getting Started ### Installation #### Using Go Install ```bash go install github.com/mark3labs/mcp-filesystem-server@latest ``` ### Usage #### As a standalone server Start the MCP server with allowed directories: ```bash mcp-filesystem-server /path/to/allowed/directory [/another/allowed/directory ...] ``` #### As a library in your Go project ```go package main import ( "log" "os" "github.com/mark3labs/mcp-filesystem-server/filesystemserver" ) func main() { // Create a new filesystem server with allowed directories allowedDirs := []string{"/path/to/allowed/directory", "/another/allowed/directory"} fs, err := filesystemserver.NewFilesystemServer(allowedDirs) if err != nil { log.Fatalf("Failed to create server: %v", err) } // Serve requests if err := fs.Serve(); err != nil { log.Fatalf("Server error: %v", err) } } ``` ### Usage with Model Context Protocol To integrate this server with apps that support MCP: ```json { "mcpServers": { "filesystem": { "command": "mcp-filesystem-server", "args": ["/path/to/allowed/directory", "/another/allowed/directory"] } } } ``` ### Docker #### Running with Docker You can run the Filesystem MCP server using Docker: ```bash docker run -i --rm ghcr.io/mark3labs/mcp-filesystem-server:latest /path/to/allowed/directory ``` #### Docker Configuration with MCP To integrate the Docker image with apps that support MCP: ```json { "mcpServers": { "filesystem": { "command": "docker", "args": [ "run", "-i", "--rm", "ghcr.io/mark3labs/mcp-filesystem-server:latest", "/path/to/allowed/directory" ] } } } ``` If you need changes made inside the container to reflect on the host filesystem, you can mount a volume. This allows the container to access and modify files on the host system. Here's an example: ```json { "mcpServers": { "filesystem": { "command": "docker", "args": [ "run", "-i", "--rm", "--volume=/allowed/directory/in/host:/allowed/directory/in/container", "ghcr.io/mark3labs/mcp-filesystem-server:latest", "/allowed/directory/in/container" ] } } } ``` ## License See the [LICENSE](LICENSE) file for details. ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- ```yaml version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "daily" groups: minor-dependencies: update-types: - "minor" - "patch" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "daily" ``` -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- ```yaml name: Test on: [push] jobs: tests: strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '>=1.21.0' check-latest: true - name: Run tests run: go test -race ./... ``` -------------------------------------------------------------------------------- /filesystemserver/inprocess_test.go: -------------------------------------------------------------------------------- ```go package filesystemserver_test import ( "testing" "github.com/mark3labs/mcp-filesystem-server/filesystemserver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestInProcess(t *testing.T) { fss, err := filesystemserver.NewFilesystemServer([]string{"."}) require.NoError(t, err) mcpClient := startTestClient(t, fss) // just check for a specific tool tool := getTool(t, mcpClient, "read_file") assert.NotNil(t, tool, "read_file tool not found in the list of tools") } ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go package main import ( "fmt" "log" "os" "github.com/mark3labs/mcp-filesystem-server/filesystemserver" "github.com/mark3labs/mcp-go/server" ) func main() { // Parse command line arguments if len(os.Args) < 2 { fmt.Fprintf( os.Stderr, "Usage: %s <allowed-directory> [additional-directories...]\n", os.Args[0], ) os.Exit(1) } // Create and start the server fss, err := filesystemserver.NewFilesystemServer(os.Args[1:]) if err != nil { log.Fatalf("Failed to create server: %v", err) } // Serve requests if err := server.ServeStdio(fss); err != nil { log.Fatalf("Server error: %v", err) } } ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile FROM golang:1.23-alpine AS builder WORKDIR /app # Copy go.mod and go.sum first for caching dependencies COPY go.mod go.sum ./ # Download dependencies RUN go mod download # Copy the source code COPY . . # Build the application RUN go build -ldflags="-s -w" -o server . FROM alpine:latest WORKDIR /app # Copy the built binary from the builder stage COPY --from=builder /app/server ./ # The container will by default pass '/app' as the allowed directory if no other command line arguments are provided ENTRYPOINT ["./server"] CMD ["/app"] ``` -------------------------------------------------------------------------------- /filesystemserver/server_test.go: -------------------------------------------------------------------------------- ```go package filesystemserver_test import ( "testing" "github.com/mark3labs/mcp-filesystem-server/filesystemserver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // regression test for invalid schema => missing items in array definition func TestReadMultipleFilesSchema(t *testing.T) { fsserver, err := filesystemserver.NewFilesystemServer([]string{t.TempDir()}) require.NoError(t, err) mcpClient := startTestClient(t, fsserver) tool := getTool(t, mcpClient, "read_multiple_files") require.NotNil(t, tool) // make sure that the tool has the correct schema paths, ok := tool.InputSchema.Properties["paths"] assert.True(t, ok) pathsMap, ok := paths.(map[string]any) assert.True(t, ok) _, ok = pathsMap["items"] assert.True(t, ok) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/handler_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "path/filepath" "testing" "github.com/stretchr/testify/require" ) // resolveAllowedDirs generates a list of allowed paths, including their resolved symlinks. // This ensures both the original paths and their symlink-resolved counterparts are included, // which is useful when paths may be symlinks (e.g., t.TempDir() on some Unix systems). func resolveAllowedDirs(t *testing.T, dirs ...string) []string { t.Helper() allowedDirs := make([]string, 0) for _, dir := range dirs { allowedDirs = append(allowedDirs, dir) resolvedPath, err := filepath.EvalSymlinks(dir) require.NoError(t, err, "Failed to resolve symlinks for directory: %s", dir) if resolvedPath != dir { allowedDirs = append(allowedDirs, resolvedPath) } } return allowedDirs } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/list_allowed_directories.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "path/filepath" "strings" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleListAllowedDirectories( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { // Remove the trailing separator for display purposes displayDirs := make([]string, len(fs.allowedDirs)) for i, dir := range fs.allowedDirs { displayDirs[i] = strings.TrimSuffix(dir, string(filepath.Separator)) } var result strings.Builder result.WriteString("Allowed directories:\n\n") for _, dir := range displayDirs { resourceURI := pathToResourceURI(dir) result.WriteString(fmt.Sprintf("%s (%s)\n", dir, resourceURI)) } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: result.String(), }, }, }, nil } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml startCommand: type: stdio configSchema: # JSON Schema defining the configuration options for the MCP. type: object required: - allowedDirectory properties: allowedDirectory: type: string description: The absolute path to an allowed directory for the filesystem server. For example, in the Docker container '/app' is a good default. additionalDirectories: type: array items: type: string description: Optional additional allowed directories. commandFunction: # A JS function that produces the CLI command based on the given config to start the MCP on stdio. |- (config) => { const args = [config.allowedDirectory]; if (config.additionalDirectories && Array.isArray(config.additionalDirectories)) { args.push(...config.additionalDirectories); } return { command: './server', args: args }; } exampleConfig: allowedDirectory: /app additionalDirectories: [] ``` -------------------------------------------------------------------------------- /filesystemserver/handler/handler.go: -------------------------------------------------------------------------------- ```go package handler import ( "fmt" "os" "path/filepath" ) type FilesystemHandler struct { allowedDirs []string } func NewFilesystemHandler(allowedDirs []string) (*FilesystemHandler, error) { // Normalize and validate directories normalized := make([]string, 0, len(allowedDirs)) for _, dir := range allowedDirs { abs, err := filepath.Abs(dir) if err != nil { return nil, fmt.Errorf("failed to resolve path %s: %w", dir, err) } info, err := os.Stat(abs) if err != nil { return nil, fmt.Errorf( "failed to access directory %s: %w", abs, err, ) } if !info.IsDir() { return nil, fmt.Errorf("path is not a directory: %s", abs) } // Ensure the path ends with a separator to prevent prefix matching issues // For example, /tmp/foo should not match /tmp/foobar normalized = append(normalized, filepath.Clean(abs)+string(filepath.Separator)) } return &FilesystemHandler{ allowedDirs: normalized, }, nil } // pathToResourceURI converts a file path to a resource URI func pathToResourceURI(path string) string { return "file://" + path } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/types.go: -------------------------------------------------------------------------------- ```go package handler import "time" const ( // Maximum size for inline content (5MB) MAX_INLINE_SIZE = 5 * 1024 * 1024 // Maximum size for base64 encoding (1MB) MAX_BASE64_SIZE = 1 * 1024 * 1024 // Maximum number of search results to return (prevent excessive output) MAX_SEARCH_RESULTS = 1000 // Maximum file size in bytes to search within (10MB) MAX_SEARCHABLE_SIZE = 10 * 1024 * 1024 ) type FileInfo struct { Size int64 `json:"size"` Created time.Time `json:"created"` Modified time.Time `json:"modified"` Accessed time.Time `json:"accessed"` IsDirectory bool `json:"isDirectory"` IsFile bool `json:"isFile"` Permissions string `json:"permissions"` } // FileNode represents a node in the file tree type FileNode struct { Name string `json:"name"` Path string `json:"path"` Type string `json:"type"` // "file" or "directory" Size int64 `json:"size,omitempty"` Modified time.Time `json:"modified,omitempty"` Children []*FileNode `json:"children,omitempty"` } // SearchResult represents a single match in a file type SearchResult struct { FilePath string LineNumber int LineContent string ResourceURI string } ``` -------------------------------------------------------------------------------- /filesystemserver/utils_test.go: -------------------------------------------------------------------------------- ```go package filesystemserver_test import ( "context" "testing" "github.com/mark3labs/mcp-filesystem-server/filesystemserver" "github.com/mark3labs/mcp-go/client" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func startTestClient(t *testing.T, fss *server.MCPServer) client.MCPClient { t.Helper() mcpClient, err := client.NewInProcessClient(fss) require.NoError(t, err) t.Cleanup(func() { mcpClient.Close() }) err = mcpClient.Start(context.Background()) require.NoError(t, err) // Initialize the client initRequest := mcp.InitializeRequest{} initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION initRequest.Params.ClientInfo = mcp.Implementation{ Name: "test-client", Version: "1.0.0", } result, err := mcpClient.Initialize(context.Background(), initRequest) require.NoError(t, err) assert.Equal(t, "secure-filesystem-server", result.ServerInfo.Name) assert.Equal(t, filesystemserver.Version, result.ServerInfo.Version) return mcpClient } func getTool(t *testing.T, mcpClient client.MCPClient, toolName string) *mcp.Tool { result, err := mcpClient.ListTools(context.Background(), mcp.ListToolsRequest{}) require.NoError(t, err) for _, tool := range result.Tools { if tool.Name == toolName { return &tool } } require.Fail(t, "Tool not found", toolName) return nil } ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml name: Release on: push: tags: - 'v*' permissions: contents: write packages: write jobs: goreleaser: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '>=1.21.0' check-latest: true - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser version: '~> v2' args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Extract version id: get-version run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - name: Login to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push Docker image uses: docker/build-push-action@v6 with: context: . push: true platforms: linux/amd64,linux/arm64 tags: | ghcr.io/${{ github.repository }}:latest ghcr.io/${{ github.repository }}:${{ steps.get-version.outputs.VERSION }} ``` -------------------------------------------------------------------------------- /filesystemserver/handler/search_files_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "os" "path/filepath" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestSearchFiles_Pattern(t *testing.T) { // setting up test folder // tmpDir/ // - foo/ // - bar.h // - test.c // - test.h // - test.c dir := t.TempDir() test_h := filepath.Join(dir, "test.h") err := os.WriteFile(test_h, []byte("foo"), 0644) require.NoError(t, err) test_c := filepath.Join(dir, "test.c") err = os.WriteFile(test_c, []byte("foo"), 0644) require.NoError(t, err) fooDir := filepath.Join(dir, "foo") err = os.MkdirAll(fooDir, 0755) require.NoError(t, err) foo_bar_h := filepath.Join(fooDir, "bar.h") err = os.WriteFile(foo_bar_h, []byte("foo"), 0644) require.NoError(t, err) foo_test_c := filepath.Join(fooDir, "test.c") err = os.WriteFile(foo_test_c, []byte("foo"), 0644) require.NoError(t, err) handler, err := NewFilesystemHandler(resolveAllowedDirs(t, dir)) require.NoError(t, err) tests := []struct { info string pattern string matches []string }{ {info: "use placeholder with extension", pattern: "*.h", matches: []string{test_h, foo_bar_h}}, {info: "use placeholder with name", pattern: "test.*", matches: []string{test_h, test_c}}, {info: "same filename", pattern: "test.c", matches: []string{test_c, foo_test_c}}, } for _, test := range tests { t.Run(test.info, func(t *testing.T) { request := mcp.CallToolRequest{} request.Params.Name = "search_files" request.Params.Arguments = map[string]any{ "path": dir, "pattern": test.pattern, } result, err := handler.HandleSearchFiles(context.Background(), request) require.NoError(t, err) assert.False(t, result.IsError) assert.Len(t, result.Content, 1) for _, match := range test.matches { assert.Contains(t, result.Content[0].(mcp.TextContent).Text, match) } }) } } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/read_file_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "path/filepath" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestReadfile_Valid(t *testing.T) { // prepare temp directory dir := t.TempDir() content := "test-content" err := os.WriteFile(filepath.Join(dir, "test"), []byte(content), 0644) require.NoError(t, err) handler, err := NewFilesystemHandler(resolveAllowedDirs(t, dir)) require.NoError(t, err) request := mcp.CallToolRequest{} request.Params.Name = "read_file" request.Params.Arguments = map[string]any{ "path": filepath.Join(dir, "test"), } result, err := handler.HandleReadFile(context.Background(), request) require.NoError(t, err) assert.Len(t, result.Content, 1) assert.Equal(t, content, result.Content[0].(mcp.TextContent).Text) } func TestReadfile_Invalid(t *testing.T) { dir := t.TempDir() handler, err := NewFilesystemHandler(resolveAllowedDirs(t, dir)) require.NoError(t, err) request := mcp.CallToolRequest{} request.Params.Name = "read_file" request.Params.Arguments = map[string]any{ "path": filepath.Join(dir, "test"), } result, err := handler.HandleReadFile(context.Background(), request) require.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, fmt.Sprint(result.Content[0]), "no such file or directory") } func TestReadfile_NoAccess(t *testing.T) { dir1 := t.TempDir() dir2 := t.TempDir() handler, err := NewFilesystemHandler(resolveAllowedDirs(t, dir1)) require.NoError(t, err) request := mcp.CallToolRequest{} request.Params.Name = "read_file" request.Params.Arguments = map[string]any{ "path": filepath.Join(dir2, "test"), } result, err := handler.HandleReadFile(context.Background(), request) require.NoError(t, err) assert.True(t, result.IsError) assert.Contains(t, fmt.Sprint(result.Content[0]), "access denied - path outside allowed directories") } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/list_allowed_directories_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandleListAllowedDirectories(t *testing.T) { // Setup multiple temporary directories for the test tmpDir1 := t.TempDir() tmpDir2 := t.TempDir() // Create a handler with multiple allowed directories allowedDirs := resolveAllowedDirs(t, tmpDir1, tmpDir2) fsHandler, err := NewFilesystemHandler(allowedDirs) require.NoError(t, err) ctx := context.Background() t.Run("list allowed directories", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{}, }, } res, err := fsHandler.HandleListAllowedDirectories(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains the allowed directories require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Allowed directories:") assert.Contains(t, textContent.Text, tmpDir1) assert.Contains(t, textContent.Text, tmpDir2) assert.Contains(t, textContent.Text, "file://") }) t.Run("single allowed directory", func(t *testing.T) { singleDir := t.TempDir() singleAllowedDirs := resolveAllowedDirs(t, singleDir) singleFsHandler, err := NewFilesystemHandler(singleAllowedDirs) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{}, }, } res, err := singleFsHandler.HandleListAllowedDirectories(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains the single allowed directory require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Allowed directories:") assert.Contains(t, textContent.Text, singleDir) assert.Contains(t, textContent.Text, "file://") }) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/create_directory_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "os" "path/filepath" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandleCreateDirectory(t *testing.T) { // Setup a temporary directory for the test tmpDir := t.TempDir() // Create a handler with the temp dir as an allowed path allowedDirs := resolveAllowedDirs(t, tmpDir) fsHandler, err := NewFilesystemHandler(allowedDirs) require.NoError(t, err) ctx := context.Background() t.Run("create a new directory", func(t *testing.T) { newDirPath := filepath.Join(tmpDir, "new_directory") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": newDirPath, }, }, } res, err := fsHandler.HandleCreateDirectory(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the directory was created info, err := os.Stat(newDirPath) require.NoError(t, err) assert.True(t, info.IsDir()) }) t.Run("directory already exists", func(t *testing.T) { existingDirPath := filepath.Join(tmpDir, "existing_directory") err := os.Mkdir(existingDirPath, 0755) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": existingDirPath, }, }, } res, err := fsHandler.HandleCreateDirectory(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Should not be an error, just a message that it already exists }) t.Run("path exists but is not a directory", func(t *testing.T) { filePath := filepath.Join(tmpDir, "existing_file.txt") err := os.WriteFile(filePath, []byte("content"), 0644) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": filePath, }, }, } res, err := fsHandler.HandleCreateDirectory(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) t.Run("path is in a non-allowed directory", func(t *testing.T) { otherDir := t.TempDir() req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": filepath.Join(otherDir, "new_directory"), }, }, } res, err := fsHandler.HandleCreateDirectory(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/create_directory.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleCreateDirectory( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { path, err := request.RequireString("path") if err != nil { return nil, err } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if path already exists if info, err := os.Stat(validPath); err == nil { if info.IsDir() { resourceURI := pathToResourceURI(validPath) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Directory already exists: %s", path), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Directory: %s", validPath), }, }, }, }, nil } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: Path exists but is not a directory: %s", path), }, }, IsError: true, }, nil } if err := os.MkdirAll(validPath, 0755); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error creating directory: %v", err), }, }, IsError: true, }, nil } resourceURI := pathToResourceURI(validPath) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Successfully created directory %s", path), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Directory: %s", validPath), }, }, }, }, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/write_file.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "path/filepath" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleWriteFile( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { path, err := request.RequireString("path") if err != nil { return nil, err } content, err := request.RequireString("content") if err != nil { return nil, err } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if it's a directory if info, err := os.Stat(validPath); err == nil && info.IsDir() { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: Cannot write to a directory", }, }, IsError: true, }, nil } // Create parent directories if they don't exist parentDir := filepath.Dir(validPath) if err := os.MkdirAll(parentDir, 0755); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error creating parent directories: %v", err), }, }, IsError: true, }, nil } if err := os.WriteFile(validPath, []byte(content), 0644); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error writing file: %v", err), }, }, IsError: true, }, nil } // Get file info for the response info, err := os.Stat(validPath) if err != nil { // File was written but we couldn't get info return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Successfully wrote to %s", path), }, }, }, nil } resourceURI := pathToResourceURI(validPath) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Successfully wrote %d bytes to %s", info.Size(), path), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("File: %s (%d bytes)", validPath, info.Size()), }, }, }, }, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/list_directory.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "path/filepath" "strings" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleListDirectory( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { path, err := request.RequireString("path") if err != nil { return nil, err } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if it's a directory info, err := os.Stat(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } if !info.IsDir() { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: Path is not a directory", }, }, IsError: true, }, nil } entries, err := os.ReadDir(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error reading directory: %v", err), }, }, IsError: true, }, nil } var result strings.Builder result.WriteString(fmt.Sprintf("Directory listing for: %s\n\n", validPath)) for _, entry := range entries { entryPath := filepath.Join(validPath, entry.Name()) resourceURI := pathToResourceURI(entryPath) if entry.IsDir() { result.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", entry.Name(), resourceURI)) } else { info, err := entry.Info() if err == nil { result.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", entry.Name(), resourceURI, info.Size())) } else { result.WriteString(fmt.Sprintf("[FILE] %s (%s)\n", entry.Name(), resourceURI)) } } } // Return both text content and embedded resource resourceURI := pathToResourceURI(validPath) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: result.String(), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Directory: %s", validPath), }, }, }, }, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/delete_file.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleDeleteFile( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { path, err := request.RequireString("path") if err != nil { return nil, err } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if path exists info, err := os.Stat(validPath) if os.IsNotExist(err) { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: Path does not exist: %s", path), }, }, IsError: true, }, nil } else if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error accessing path: %v", err), }, }, IsError: true, }, nil } // Extract recursive parameter (optional, default: false) recursive := false if recursiveParam, err := request.RequireBool("recursive"); err == nil { recursive = recursiveParam } // Check if it's a directory and handle accordingly if info.IsDir() { if !recursive { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %s is a directory. Use recursive=true to delete directories.", path), }, }, IsError: true, }, nil } // It's a directory and recursive is true, so remove it if err := os.RemoveAll(validPath); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error deleting directory: %v", err), }, }, IsError: true, }, nil } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Successfully deleted directory %s", path), }, }, }, nil } // It's a file, delete it if err := os.Remove(validPath); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error deleting file: %v", err), }, }, IsError: true, }, nil } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Successfully deleted file %s", path), }, }, }, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/get_file_info.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "time" "github.com/djherbis/times" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleGetFileInfo( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { path, err := request.RequireString("path") if err != nil { return nil, err } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } info, err := fs.getFileStats(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error getting file info: %v", err), }, }, IsError: true, }, nil } // Get MIME type for files mimeType := "directory" if info.IsFile { mimeType = detectMimeType(validPath) } resourceURI := pathToResourceURI(validPath) // Determine file type text var fileTypeText string if info.IsDirectory { fileTypeText = "Directory" } else { fileTypeText = "File" } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf( "File information for: %s\n\nSize: %d bytes\nCreated: %s\nModified: %s\nAccessed: %s\nIsDirectory: %v\nIsFile: %v\nPermissions: %s\nMIME Type: %s\nResource URI: %s", validPath, info.Size, info.Created.Format(time.RFC3339), info.Modified.Format(time.RFC3339), info.Accessed.Format(time.RFC3339), info.IsDirectory, info.IsFile, info.Permissions, mimeType, resourceURI, ), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("%s: %s (%s, %d bytes)", fileTypeText, validPath, mimeType, info.Size), }, }, }, }, nil } func (fs *FilesystemHandler) getFileStats(path string) (FileInfo, error) { info, err := os.Stat(path) if err != nil { return FileInfo{}, err } timespec, err := times.Stat(path) if err != nil { return FileInfo{}, fmt.Errorf("failed to get file times: %w", err) } createdTime := time.Time{} if timespec.HasBirthTime() { createdTime = timespec.BirthTime() } return FileInfo{ Size: info.Size(), Created: createdTime, Modified: timespec.ModTime(), Accessed: timespec.AccessTime(), IsDirectory: info.IsDir(), IsFile: !info.IsDir(), Permissions: fmt.Sprintf("%o", info.Mode().Perm()), }, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/get_file_info_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "os" "path/filepath" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandleGetFileInfo(t *testing.T) { // Setup a temporary directory for the test tmpDir := t.TempDir() // Create a handler with the temp dir as an allowed path allowedDirs := resolveAllowedDirs(t, tmpDir) fsHandler, err := NewFilesystemHandler(allowedDirs) require.NoError(t, err) ctx := context.Background() t.Run("get file info for a file", func(t *testing.T) { filePath := filepath.Join(tmpDir, "test_file.txt") fileContent := "Hello, world!" err := os.WriteFile(filePath, []byte(fileContent), 0644) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": filePath, }, }, } res, err := fsHandler.HandleGetFileInfo(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains file information require.Len(t, res.Content, 2) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "File information for:") assert.Contains(t, textContent.Text, filePath) assert.Contains(t, textContent.Text, "IsFile: true") assert.Contains(t, textContent.Text, "IsDirectory: false") assert.Contains(t, textContent.Text, "Size: 13 bytes") // Length of "Hello, world!" }) t.Run("get file info for a directory", func(t *testing.T) { dirPath := filepath.Join(tmpDir, "test_directory") err := os.Mkdir(dirPath, 0755) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": dirPath, }, }, } res, err := fsHandler.HandleGetFileInfo(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains directory information require.Len(t, res.Content, 2) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "File information for:") assert.Contains(t, textContent.Text, dirPath) assert.Contains(t, textContent.Text, "IsFile: false") assert.Contains(t, textContent.Text, "IsDirectory: true") assert.Contains(t, textContent.Text, "MIME Type: directory") }) t.Run("file does not exist", func(t *testing.T) { nonExistentPath := filepath.Join(tmpDir, "non_existent_file.txt") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": nonExistentPath, }, }, } res, err := fsHandler.HandleGetFileInfo(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) t.Run("path is in a non-allowed directory", func(t *testing.T) { otherDir := t.TempDir() req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": filepath.Join(otherDir, "some_file.txt"), }, }, } res, err := fsHandler.HandleGetFileInfo(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/resources.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "encoding/base64" "fmt" "os" "path/filepath" "strings" "github.com/mark3labs/mcp-go/mcp" ) // HandleReadResource handles the MCP resource reading functionality func (fs *FilesystemHandler) HandleReadResource( ctx context.Context, request mcp.ReadResourceRequest, ) ([]mcp.ResourceContents, error) { uri := request.Params.URI // Check if it's a file:// URI if !strings.HasPrefix(uri, "file://") { return nil, fmt.Errorf("unsupported URI scheme: %s", uri) } // Extract the path from the URI path := strings.TrimPrefix(uri, "file://") // Validate the path validPath, err := fs.validatePath(path) if err != nil { return nil, err } // Get file info fileInfo, err := os.Stat(validPath) if err != nil { return nil, err } // If it's a directory, return a listing if fileInfo.IsDir() { entries, err := os.ReadDir(validPath) if err != nil { return nil, err } var result strings.Builder result.WriteString(fmt.Sprintf("Directory listing for: %s\n\n", validPath)) for _, entry := range entries { entryPath := filepath.Join(validPath, entry.Name()) entryURI := pathToResourceURI(entryPath) if entry.IsDir() { result.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", entry.Name(), entryURI)) } else { info, err := entry.Info() if err == nil { result.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", entry.Name(), entryURI, info.Size())) } else { result.WriteString(fmt.Sprintf("[FILE] %s (%s)\n", entry.Name(), entryURI)) } } } return []mcp.ResourceContents{ mcp.TextResourceContents{ URI: uri, MIMEType: "text/plain", Text: result.String(), }, }, nil } // It's a file, determine how to handle it mimeType := detectMimeType(validPath) // Check file size if fileInfo.Size() > MAX_INLINE_SIZE { // File is too large to inline, return a reference instead return []mcp.ResourceContents{ mcp.TextResourceContents{ URI: uri, MIMEType: "text/plain", Text: fmt.Sprintf("File is too large to display inline (%d bytes). Use the read_file tool to access specific portions.", fileInfo.Size()), }, }, nil } // Read the file content content, err := os.ReadFile(validPath) if err != nil { return nil, err } // Handle based on content type if isTextFile(mimeType) { // It's a text file, return as text return []mcp.ResourceContents{ mcp.TextResourceContents{ URI: uri, MIMEType: mimeType, Text: string(content), }, }, nil } else { // It's a binary file if fileInfo.Size() <= MAX_BASE64_SIZE { // Small enough for base64 encoding return []mcp.ResourceContents{ mcp.BlobResourceContents{ URI: uri, MIMEType: mimeType, Blob: base64.StdEncoding.EncodeToString(content), }, }, nil } else { // Too large for base64, return a reference return []mcp.ResourceContents{ mcp.TextResourceContents{ URI: uri, MIMEType: "text/plain", Text: fmt.Sprintf("Binary file (%s, %d bytes). Use the read_file tool to access specific portions.", mimeType, fileInfo.Size()), }, }, nil } } } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/copy_file_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "os" "path/filepath" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandleCopyFile(t *testing.T) { // Setup a temporary directory for the test tmpDir := t.TempDir() // Create a handler with the temp dir as an allowed path allowedDirs := resolveAllowedDirs(t, tmpDir) fsHandler, err := NewFilesystemHandler(allowedDirs) require.NoError(t, err) ctx := context.Background() // Create a source file sourceFilePath := filepath.Join(tmpDir, "source.txt") err = os.WriteFile(sourceFilePath, []byte("hello world"), 0644) require.NoError(t, err) // Create a source directory sourceDirPath := filepath.Join(tmpDir, "source_dir") err = os.Mkdir(sourceDirPath, 0755) require.NoError(t, err) // Create a file inside the source directory nestedFilePath := filepath.Join(sourceDirPath, "nested.txt") err = os.WriteFile(nestedFilePath, []byte("nested hello"), 0644) require.NoError(t, err) t.Run("copy a single file", func(t *testing.T) { destinationPath := filepath.Join(tmpDir, "destination.txt") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "source": sourceFilePath, "destination": destinationPath, }, }, } res, err := fsHandler.HandleCopyFile(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the file was copied _, err = os.Stat(destinationPath) require.NoError(t, err) content, err := os.ReadFile(destinationPath) require.NoError(t, err) assert.Equal(t, "hello world", string(content)) }) t.Run("copy a directory", func(t *testing.T) { destinationPath := filepath.Join(tmpDir, "destination_dir") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "source": sourceDirPath, "destination": destinationPath, }, }, } res, err := fsHandler.HandleCopyFile(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the directory was copied _, err = os.Stat(destinationPath) require.NoError(t, err) // Verify the nested file was copied nestedDestPath := filepath.Join(destinationPath, "nested.txt") _, err = os.Stat(nestedDestPath) require.NoError(t, err) content, err := os.ReadFile(nestedDestPath) require.NoError(t, err) assert.Equal(t, "nested hello", string(content)) }) t.Run("source does not exist", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "source": filepath.Join(tmpDir, "non-existent-file.txt"), "destination": filepath.Join(tmpDir, "destination.txt"), }, }, } res, err := fsHandler.HandleCopyFile(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) t.Run("destination is in a non-allowed directory", func(t *testing.T) { // Setup a temporary directory for the test otherDir := t.TempDir() req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "source": sourceFilePath, "destination": filepath.Join(otherDir, "destination.txt"), }, }, } res, err := fsHandler.HandleCopyFile(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/search_files.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "path/filepath" "strings" "github.com/gobwas/glob" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleSearchFiles( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { path, err := request.RequireString("path") if err != nil { return nil, err } pattern, err := request.RequireString("pattern") if err != nil { return nil, err } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if it's a directory info, err := os.Stat(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } if !info.IsDir() { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: Search path must be a directory", }, }, IsError: true, }, nil } results, err := searchFiles(validPath, pattern, fs) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error searching files: %v", err), }, }, IsError: true, }, nil } if len(results) == 0 { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("No files found matching pattern '%s' in %s", pattern, path), }, }, }, nil } // Format results with resource URIs var formattedResults strings.Builder formattedResults.WriteString(fmt.Sprintf("Found %d results:\n\n", len(results))) for _, result := range results { resourceURI := pathToResourceURI(result) info, err := os.Stat(result) if err == nil { if info.IsDir() { formattedResults.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", result, resourceURI)) } else { formattedResults.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", result, resourceURI, info.Size())) } } else { formattedResults.WriteString(fmt.Sprintf("%s (%s)\n", result, resourceURI)) } } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: formattedResults.String(), }, }, }, nil } func searchFiles(rootPath, pattern string, fs *FilesystemHandler) ([]string, error) { var results []string globPattern := glob.MustCompile(pattern) err := filepath.Walk( rootPath, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors and continue } // Try to validate path if _, err := fs.validatePath(path); err != nil { return nil // Skip invalid paths } if globPattern.Match(info.Name()) { results = append(results, path) } return nil }, ) if err != nil { return nil, err } return results, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/move_file.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "path/filepath" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleMoveFile( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { source, err := request.RequireString("source") if err != nil { return nil, err } destination, err := request.RequireString("destination") if err != nil { return nil, err } // Handle empty or relative paths for source if source == "." || source == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } source = cwd } // Handle empty or relative paths for destination if destination == "." || destination == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } destination = cwd } validSource, err := fs.validatePath(source) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error with source path: %v", err), }, }, IsError: true, }, nil } // Check if source exists if _, err := os.Stat(validSource); os.IsNotExist(err) { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: Source does not exist: %s", source), }, }, IsError: true, }, nil } // For destination path, validate the parent directory first and create it if needed destDir := filepath.Dir(destination) validDestDir, err := fs.validatePath(destDir) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error with destination directory path: %v", err), }, }, IsError: true, }, nil } // Create parent directory for destination if it doesn't exist if err := os.MkdirAll(validDestDir, 0755); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error creating destination directory: %v", err), }, }, IsError: true, }, nil } // Now validate the full destination path validDest, err := fs.validatePath(destination) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error with destination path: %v", err), }, }, IsError: true, }, nil } if err := os.Rename(validSource, validDest); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error moving file: %v", err), }, }, IsError: true, }, nil } resourceURI := pathToResourceURI(validDest) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf( "Successfully moved %s to %s", source, destination, ), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Moved file: %s", validDest), }, }, }, }, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/list_directory_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "os" "path/filepath" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandleListDirectory(t *testing.T) { // Setup a temporary directory for the test tmpDir := t.TempDir() // Create a handler with the temp dir as an allowed path allowedDirs := resolveAllowedDirs(t, tmpDir) fsHandler, err := NewFilesystemHandler(allowedDirs) require.NoError(t, err) ctx := context.Background() // Create test directory structure subDir := filepath.Join(tmpDir, "subdirectory") err = os.Mkdir(subDir, 0755) require.NoError(t, err) testFile := filepath.Join(tmpDir, "test_file.txt") err = os.WriteFile(testFile, []byte("hello world"), 0644) require.NoError(t, err) t.Run("list directory with files and subdirectories", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": tmpDir, }, }, } res, err := fsHandler.HandleListDirectory(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains directory listing require.Len(t, res.Content, 2) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Directory listing for:") assert.Contains(t, textContent.Text, tmpDir) assert.Contains(t, textContent.Text, "[DIR] subdirectory") assert.Contains(t, textContent.Text, "[FILE] test_file.txt") assert.Contains(t, textContent.Text, "11 bytes") // Length of "hello world" assert.Contains(t, textContent.Text, "file://") // Verify embedded resource embeddedResource := res.Content[1].(mcp.EmbeddedResource) assert.Equal(t, "resource", embeddedResource.Type) }) t.Run("list empty directory", func(t *testing.T) { emptyDir := filepath.Join(tmpDir, "empty_directory") err := os.Mkdir(emptyDir, 0755) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": emptyDir, }, }, } res, err := fsHandler.HandleListDirectory(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains directory listing for empty directory require.Len(t, res.Content, 2) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Directory listing for:") assert.Contains(t, textContent.Text, emptyDir) }) t.Run("try to list a file instead of directory", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": testFile, }, }, } res, err := fsHandler.HandleListDirectory(ctx, req) require.NoError(t, err) require.True(t, res.IsError) // Verify error message require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Path is not a directory") }) t.Run("try to list non-existent directory", func(t *testing.T) { nonExistentPath := filepath.Join(tmpDir, "non_existent_directory") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": nonExistentPath, }, }, } res, err := fsHandler.HandleListDirectory(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) t.Run("path is in a non-allowed directory", func(t *testing.T) { otherDir := t.TempDir() req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": otherDir, }, }, } res, err := fsHandler.HandleListDirectory(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/helper.go: -------------------------------------------------------------------------------- ```go package handler import ( "fmt" "mime" "os" "path/filepath" "slices" "strings" "github.com/gabriel-vasile/mimetype" ) // isPathInAllowedDirs checks if a path is within any of the allowed directories func (fs *FilesystemHandler) isPathInAllowedDirs(path string) bool { // Ensure path is absolute and clean absPath, err := filepath.Abs(path) if err != nil { return false } // Add trailing separator to ensure we're checking a directory or a file within a directory // and not a prefix match (e.g., /tmp/foo should not match /tmp/foobar) if !strings.HasSuffix(absPath, string(filepath.Separator)) { // If it's a file, we need to check its directory if info, err := os.Stat(absPath); err == nil && !info.IsDir() { absPath = filepath.Dir(absPath) + string(filepath.Separator) } else { absPath = absPath + string(filepath.Separator) } } // Check if the path is within any of the allowed directories for _, dir := range fs.allowedDirs { if strings.HasPrefix(absPath, dir) { return true } } return false } func (fs *FilesystemHandler) validatePath(requestedPath string) (string, error) { // Always convert to absolute path first abs, err := filepath.Abs(requestedPath) if err != nil { return "", fmt.Errorf("invalid path: %w", err) } // Check if path is within allowed directories if !fs.isPathInAllowedDirs(abs) { return "", fmt.Errorf( "access denied - path outside allowed directories: %s", abs, ) } // Handle symlinks realPath, err := filepath.EvalSymlinks(abs) if err != nil { if !os.IsNotExist(err) { return "", err } // For new files, check parent directory parent := filepath.Dir(abs) realParent, err := filepath.EvalSymlinks(parent) if err != nil { return "", fmt.Errorf("parent directory does not exist: %s", parent) } if !fs.isPathInAllowedDirs(realParent) { return "", fmt.Errorf( "access denied - parent directory outside allowed directories", ) } return abs, nil } // Check if the real path (after resolving symlinks) is still within allowed directories if !fs.isPathInAllowedDirs(realPath) { return "", fmt.Errorf( "access denied - symlink target outside allowed directories", ) } return realPath, nil } // detectMimeType tries to determine the MIME type of a file func detectMimeType(path string) string { // Use mimetype library for more accurate detection mtype, err := mimetype.DetectFile(path) if err != nil { // Fallback to extension-based detection if file can't be read ext := filepath.Ext(path) if ext != "" { mimeType := mime.TypeByExtension(ext) if mimeType != "" { return mimeType } } return "application/octet-stream" // Default } return mtype.String() } // isTextFile determines if a file is likely a text file based on MIME type func isTextFile(mimeType string) bool { // Check for common text MIME types if strings.HasPrefix(mimeType, "text/") { return true } // Common application types that are text-based textApplicationTypes := []string{ "application/json", "application/xml", "application/javascript", "application/x-javascript", "application/typescript", "application/x-typescript", "application/x-yaml", "application/yaml", "application/toml", "application/x-sh", "application/x-shellscript", } if slices.Contains(textApplicationTypes, mimeType) { return true } // Check for +format types if strings.Contains(mimeType, "+xml") || strings.Contains(mimeType, "+json") || strings.Contains(mimeType, "+yaml") { return true } // Common code file types that might be misidentified if strings.HasPrefix(mimeType, "text/x-") { return true } if strings.HasPrefix(mimeType, "application/x-") && (strings.Contains(mimeType, "script") || strings.Contains(mimeType, "source") || strings.Contains(mimeType, "code")) { return true } return false } // isImageFile determines if a file is an image based on MIME type func isImageFile(mimeType string) bool { return strings.HasPrefix(mimeType, "image/") || (mimeType == "application/xml" && strings.HasSuffix(strings.ToLower(mimeType), ".svg")) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/delete_file_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "os" "path/filepath" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandleDeleteFile(t *testing.T) { // Setup a temporary directory for the test tmpDir := t.TempDir() // Create a handler with the temp dir as an allowed path allowedDirs := resolveAllowedDirs(t, tmpDir) fsHandler, err := NewFilesystemHandler(allowedDirs) require.NoError(t, err) ctx := context.Background() t.Run("delete a file", func(t *testing.T) { filePath := filepath.Join(tmpDir, "test_file.txt") err := os.WriteFile(filePath, []byte("test content"), 0644) require.NoError(t, err) // Verify file exists before deletion _, err = os.Stat(filePath) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": filePath, }, }, } res, err := fsHandler.HandleDeleteFile(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify file was deleted _, err = os.Stat(filePath) assert.True(t, os.IsNotExist(err)) }) t.Run("delete an empty directory with recursive=true", func(t *testing.T) { dirPath := filepath.Join(tmpDir, "empty_directory") err := os.Mkdir(dirPath, 0755) require.NoError(t, err) // Verify directory exists before deletion _, err = os.Stat(dirPath) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": dirPath, "recursive": true, }, }, } res, err := fsHandler.HandleDeleteFile(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify directory was deleted _, err = os.Stat(dirPath) assert.True(t, os.IsNotExist(err)) }) t.Run("delete a directory with contents using recursive=true", func(t *testing.T) { dirPath := filepath.Join(tmpDir, "directory_with_contents") err := os.Mkdir(dirPath, 0755) require.NoError(t, err) // Create a file inside the directory filePath := filepath.Join(dirPath, "nested_file.txt") err = os.WriteFile(filePath, []byte("nested content"), 0644) require.NoError(t, err) // Create a subdirectory subDirPath := filepath.Join(dirPath, "subdirectory") err = os.Mkdir(subDirPath, 0755) require.NoError(t, err) // Verify directory and contents exist before deletion _, err = os.Stat(dirPath) require.NoError(t, err) _, err = os.Stat(filePath) require.NoError(t, err) _, err = os.Stat(subDirPath) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": dirPath, "recursive": true, }, }, } res, err := fsHandler.HandleDeleteFile(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify directory and all contents were deleted _, err = os.Stat(dirPath) assert.True(t, os.IsNotExist(err)) }) t.Run("try to delete directory without recursive flag", func(t *testing.T) { dirPath := filepath.Join(tmpDir, "directory_no_recursive") err := os.Mkdir(dirPath, 0755) require.NoError(t, err) req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": dirPath, }, }, } res, err := fsHandler.HandleDeleteFile(ctx, req) require.NoError(t, err) require.True(t, res.IsError) // Verify directory still exists _, err = os.Stat(dirPath) require.NoError(t, err) }) t.Run("try to delete non-existent file", func(t *testing.T) { nonExistentPath := filepath.Join(tmpDir, "non_existent_file.txt") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": nonExistentPath, }, }, } res, err := fsHandler.HandleDeleteFile(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) t.Run("path is in a non-allowed directory", func(t *testing.T) { otherDir := t.TempDir() req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": filepath.Join(otherDir, "some_file.txt"), }, }, } res, err := fsHandler.HandleDeleteFile(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/read_multiple_files.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "encoding/base64" "fmt" "os" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleReadMultipleFiles( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { pathsSlice, err := request.RequireStringSlice("paths") if err != nil { return nil, err } if len(pathsSlice) == 0 { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "No files specified to read", }, }, IsError: true, }, nil } // Maximum number of files to read in a single request const maxFiles = 50 if len(pathsSlice) > maxFiles { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Too many files requested. Maximum is %d files per request.", maxFiles), }, }, IsError: true, }, nil } // Process each file var results []mcp.Content for _, path := range pathsSlice { // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory for path '%s': %v", path, err), }) continue } path = cwd } validPath, err := fs.validatePath(path) if err != nil { results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error with path '%s': %v", path, err), }) continue } // Check if it's a directory info, err := os.Stat(validPath) if err != nil { results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error accessing '%s': %v", path, err), }) continue } if info.IsDir() { // For directories, return a resource reference instead resourceURI := pathToResourceURI(validPath) results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("'%s' is a directory. Use list_directory tool or resource URI: %s", path, resourceURI), }) continue } // Determine MIME type mimeType := detectMimeType(validPath) // Check file size if info.Size() > MAX_INLINE_SIZE { // File is too large to inline, return a resource reference resourceURI := pathToResourceURI(validPath) results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("File '%s' is too large to display inline (%d bytes). Access it via resource URI: %s", path, info.Size(), resourceURI), }) continue } // Read file content content, err := os.ReadFile(validPath) if err != nil { results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error reading file '%s': %v", path, err), }) continue } // Add file header results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("--- File: %s ---", path), }) // Check if it's a text file if isTextFile(mimeType) { // It's a text file, return as text results = append(results, mcp.TextContent{ Type: "text", Text: string(content), }) } else if isImageFile(mimeType) { // It's an image file, return as image content if info.Size() <= MAX_BASE64_SIZE { results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Image file: %s (%s, %d bytes)", path, mimeType, info.Size()), }) results = append(results, mcp.ImageContent{ Type: "image", Data: base64.StdEncoding.EncodeToString(content), MIMEType: mimeType, }) } else { // Too large for base64, return a reference resourceURI := pathToResourceURI(validPath) results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Image file '%s' is too large to display inline (%d bytes). Access it via resource URI: %s", path, info.Size(), resourceURI), }) } } else { // It's another type of binary file resourceURI := pathToResourceURI(validPath) if info.Size() <= MAX_BASE64_SIZE { // Small enough for base64 encoding results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", path, mimeType, info.Size()), }) results = append(results, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.BlobResourceContents{ URI: resourceURI, MIMEType: mimeType, Blob: base64.StdEncoding.EncodeToString(content), }, }) } else { // Too large for base64, return a reference results = append(results, mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Binary file '%s' (%s, %d bytes). Access it via resource URI: %s", path, mimeType, info.Size(), resourceURI), }) } } } return &mcp.CallToolResult{ Content: results, }, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/tree.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "encoding/json" "fmt" "os" "path/filepath" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleTree( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { path, err := request.RequireString("path") if err != nil { return nil, err } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } // Extract depth parameter (optional, default: 3) depth := 3 // Default value if depthParam, err := request.RequireFloat("depth"); err == nil { depth = int(depthParam) } // Extract follow_symlinks parameter (optional, default: false) followSymlinks := false // Default value if followParam, err := request.RequireBool("follow_symlinks"); err == nil { followSymlinks = followParam } // Validate the path is within allowed directories validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if it's a directory info, err := os.Stat(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } if !info.IsDir() { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: The specified path is not a directory", }, }, IsError: true, }, nil } // Build the tree structure tree, err := fs.buildTree(validPath, depth, 0, followSymlinks) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error building directory tree: %v", err), }, }, IsError: true, }, nil } // Convert to JSON jsonData, err := json.MarshalIndent(tree, "", " ") if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error generating JSON: %v", err), }, }, IsError: true, }, nil } // Create resource URI for the directory resourceURI := pathToResourceURI(validPath) // Return the result return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Directory tree for %s (max depth: %d):\n\n%s", validPath, depth, string(jsonData)), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "application/json", Text: string(jsonData), }, }, }, }, nil } // buildTree builds a tree representation of the filesystem starting at the given path func (fs *FilesystemHandler) buildTree(path string, maxDepth int, currentDepth int, followSymlinks bool) (*FileNode, error) { // Validate the path validPath, err := fs.validatePath(path) if err != nil { return nil, err } // Get file info info, err := os.Stat(validPath) if err != nil { return nil, err } // Create the node node := &FileNode{ Name: filepath.Base(validPath), Path: validPath, Modified: info.ModTime(), } // Set type and size if info.IsDir() { node.Type = "directory" // If we haven't reached the max depth, process children if currentDepth < maxDepth { // Read directory entries entries, err := os.ReadDir(validPath) if err != nil { return nil, err } // Process each entry for _, entry := range entries { entryPath := filepath.Join(validPath, entry.Name()) // Handle symlinks if entry.Type()&os.ModeSymlink != 0 { if !followSymlinks { // Skip symlinks if not following them continue } // Resolve symlink linkDest, err := filepath.EvalSymlinks(entryPath) if err != nil { // Skip invalid symlinks continue } // Validate the symlink destination is within allowed directories if !fs.isPathInAllowedDirs(linkDest) { // Skip symlinks pointing outside allowed directories continue } entryPath = linkDest } // Recursively build child node childNode, err := fs.buildTree(entryPath, maxDepth, currentDepth+1, followSymlinks) if err != nil { // Skip entries with errors continue } // Add child to the current node node.Children = append(node.Children, childNode) } } } else { node.Type = "file" node.Size = info.Size() } return node, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/modify_file.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "os" "regexp" "strings" "github.com/mark3labs/mcp-go/mcp" ) // handleModifyFile handles the modify_file tool request func (fs *FilesystemHandler) HandleModifyFile( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { // Extract arguments path, err := request.RequireString("path") if err != nil { return nil, err } find, err := request.RequireString("find") if err != nil { return nil, err } replace, err := request.RequireString("replace") if err != nil { return nil, err } // Extract optional arguments with defaults allOccurrences := true // Default value if val, err := request.RequireBool("all_occurrences"); err == nil { allOccurrences = val } useRegex := false // Default value if val, err := request.RequireBool("regex"); err == nil { useRegex = val } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } // Validate path is within allowed directories validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if it's a directory if info, err := os.Stat(validPath); err == nil && info.IsDir() { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: Cannot modify a directory", }, }, IsError: true, }, nil } // Check if file exists if _, err := os.Stat(validPath); os.IsNotExist(err) { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: File not found: %s", path), }, }, IsError: true, }, nil } // Read file content content, err := os.ReadFile(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error reading file: %v", err), }, }, IsError: true, }, nil } originalContent := string(content) modifiedContent := "" replacementCount := 0 // Perform the replacement if useRegex { re, err := regexp.Compile(find) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: Invalid regular expression: %v", err), }, }, IsError: true, }, nil } if allOccurrences { modifiedContent = re.ReplaceAllString(originalContent, replace) replacementCount = len(re.FindAllString(originalContent, -1)) } else { matched := re.FindStringIndex(originalContent) if matched != nil { replacementCount = 1 modifiedContent = originalContent[:matched[0]] + replace + originalContent[matched[1]:] } else { modifiedContent = originalContent replacementCount = 0 } } } else { if allOccurrences { replacementCount = strings.Count(originalContent, find) modifiedContent = strings.ReplaceAll(originalContent, find, replace) } else { if index := strings.Index(originalContent, find); index != -1 { replacementCount = 1 modifiedContent = originalContent[:index] + replace + originalContent[index+len(find):] } else { modifiedContent = originalContent replacementCount = 0 } } } // Write modified content back to file if err := os.WriteFile(validPath, []byte(modifiedContent), 0644); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error writing to file: %v", err), }, }, IsError: true, }, nil } // Create response resourceURI := pathToResourceURI(validPath) // Get file info for the response info, err := os.Stat(validPath) if err != nil { // File was written but we couldn't get info return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("File modified successfully. Made %d replacement(s).", replacementCount), }, }, }, nil } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("File modified successfully. Made %d replacement(s) in %s (file size: %d bytes)", replacementCount, path, info.Size()), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Modified file: %s (%d bytes)", validPath, info.Size()), }, }, }, }, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/copy_file.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "fmt" "io" "os" "path/filepath" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleCopyFile( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { source, err := request.RequireString("source") if err != nil { return nil, err } destination, err := request.RequireString("destination") if err != nil { return nil, err } // Handle empty or relative paths for source if source == "." || source == "./" { cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } source = cwd } if destination == "." || destination == "./" { cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } destination = cwd } validSource, err := fs.validatePath(source) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error with source path: %v", err), }, }, IsError: true, }, nil } // Check if source exists srcInfo, err := os.Stat(validSource) if os.IsNotExist(err) { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: Source does not exist: %s", source), }, }, IsError: true, }, nil } else if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error accessing source: %v", err), }, }, IsError: true, }, nil } validDest, err := fs.validatePath(destination) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error with destination path: %v", err), }, }, IsError: true, }, nil } // Create parent directory for destination if it doesn't exist destDir := filepath.Dir(validDest) if err := os.MkdirAll(destDir, 0755); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error creating destination directory: %v", err), }, }, IsError: true, }, nil } // Perform the copy operation based on whether source is a file or directory if srcInfo.IsDir() { // It's a directory, copy recursively if err := copyDir(validSource, validDest); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error copying directory: %v", err), }, }, IsError: true, }, nil } } else { // It's a file, copy directly if err := copyFile(validSource, validDest); err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error copying file: %v", err), }, }, IsError: true, }, nil } } resourceURI := pathToResourceURI(validDest) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf( "Successfully copied %s to %s", source, destination, ), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Copied file: %s", validDest), }, }, }, }, nil } // copyFile copies a single file from src to dst func copyFile(src, dst string) error { // Open the source file sourceFile, err := os.Open(src) if err != nil { return err } defer sourceFile.Close() // Create the destination file destFile, err := os.Create(dst) if err != nil { return err } defer destFile.Close() // Copy the contents if _, err := io.Copy(destFile, sourceFile); err != nil { return err } // Get source file mode sourceInfo, err := os.Stat(src) if err != nil { return err } // Set the same file mode on destination return os.Chmod(dst, sourceInfo.Mode()) } // copyDir recursively copies a directory tree from src to dst func copyDir(src, dst string) error { // Get properties of source dir srcInfo, err := os.Stat(src) if err != nil { return err } // Create the destination directory with the same permissions if err = os.MkdirAll(dst, srcInfo.Mode()); err != nil { return err } // Read directory entries entries, err := os.ReadDir(src) if err != nil { return err } for _, entry := range entries { srcPath := filepath.Join(src, entry.Name()) dstPath := filepath.Join(dst, entry.Name()) // Handle symlinks if entry.Type()&os.ModeSymlink != 0 { // For simplicity, we'll skip symlinks in this implementation continue } // Recursively copy subdirectories or copy files if entry.IsDir() { if err = copyDir(srcPath, dstPath); err != nil { return err } } else { if err = copyFile(srcPath, dstPath); err != nil { return err } } } return nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/read_file.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "encoding/base64" "fmt" "os" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleReadFile( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { path, err := request.RequireString("path") if err != nil { return nil, err } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if it's a directory info, err := os.Stat(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } if info.IsDir() { // For directories, return a resource reference instead resourceURI := pathToResourceURI(validPath) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("This is a directory. Use the resource URI to browse its contents: %s", resourceURI), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Directory: %s", validPath), }, }, }, }, nil } // Determine MIME type mimeType := detectMimeType(validPath) // Check file size if info.Size() > MAX_INLINE_SIZE { // File is too large to inline, return a resource reference resourceURI := pathToResourceURI(validPath) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("File is too large to display inline (%d bytes). Access it via resource URI: %s", info.Size(), resourceURI), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Large file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), }, }, }, }, nil } // Read file content content, err := os.ReadFile(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error reading file: %v", err), }, }, IsError: true, }, nil } // Check if it's a text file if isTextFile(mimeType) { // It's a text file, return as text return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: string(content), }, }, }, nil } else if isImageFile(mimeType) { // It's an image file, return as image content if info.Size() <= MAX_BASE64_SIZE { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Image file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), }, mcp.ImageContent{ Type: "image", Data: base64.StdEncoding.EncodeToString(content), MIMEType: mimeType, }, }, }, nil } else { // Too large for base64, return a reference resourceURI := pathToResourceURI(validPath) return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Image file is too large to display inline (%d bytes). Access it via resource URI: %s", info.Size(), resourceURI), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Large image: %s (%s, %d bytes)", validPath, mimeType, info.Size()), }, }, }, }, nil } } else { // It's another type of binary file resourceURI := pathToResourceURI(validPath) if info.Size() <= MAX_BASE64_SIZE { // Small enough for base64 encoding return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.BlobResourceContents{ URI: resourceURI, MIMEType: mimeType, Blob: base64.StdEncoding.EncodeToString(content), }, }, }, }, nil } else { // Too large for base64, return a reference return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Binary file: %s (%s, %d bytes). Access it via resource URI: %s", validPath, mimeType, info.Size(), resourceURI), }, mcp.EmbeddedResource{ Type: "resource", Resource: mcp.TextResourceContents{ URI: resourceURI, MIMEType: "text/plain", Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), }, }, }, }, nil } } } ``` -------------------------------------------------------------------------------- /filesystemserver/server.go: -------------------------------------------------------------------------------- ```go package filesystemserver import ( "github.com/mark3labs/mcp-filesystem-server/filesystemserver/handler" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) var Version = "dev" func NewFilesystemServer(allowedDirs []string) (*server.MCPServer, error) { h, err := handler.NewFilesystemHandler(allowedDirs) if err != nil { return nil, err } s := server.NewMCPServer( "secure-filesystem-server", Version, server.WithResourceCapabilities(true, true), ) // Register resource handlers s.AddResource(mcp.NewResource( "file://", "File System", mcp.WithResourceDescription("Access to files and directories on the local file system"), ), h.HandleReadResource) // Register tool handlers s.AddTool(mcp.NewTool( "read_file", mcp.WithDescription("Read the complete contents of a file from the file system."), mcp.WithString("path", mcp.Description("Path to the file to read"), mcp.Required(), ), ), h.HandleReadFile) s.AddTool(mcp.NewTool( "write_file", mcp.WithDescription("Create a new file or overwrite an existing file with new content."), mcp.WithString("path", mcp.Description("Path where to write the file"), mcp.Required(), ), mcp.WithString("content", mcp.Description("Content to write to the file"), mcp.Required(), ), ), h.HandleWriteFile) s.AddTool(mcp.NewTool( "list_directory", mcp.WithDescription("Get a detailed listing of all files and directories in a specified path."), mcp.WithString("path", mcp.Description("Path of the directory to list"), mcp.Required(), ), ), h.HandleListDirectory) s.AddTool(mcp.NewTool( "create_directory", mcp.WithDescription("Create a new directory or ensure a directory exists."), mcp.WithString("path", mcp.Description("Path of the directory to create"), mcp.Required(), ), ), h.HandleCreateDirectory) s.AddTool(mcp.NewTool( "copy_file", mcp.WithDescription("Copy files and directories."), mcp.WithString("source", mcp.Description("Source path of the file or directory"), mcp.Required(), ), mcp.WithString("destination", mcp.Description("Destination path"), mcp.Required(), ), ), h.HandleCopyFile) s.AddTool(mcp.NewTool( "move_file", mcp.WithDescription("Move or rename files and directories."), mcp.WithString("source", mcp.Description("Source path of the file or directory"), mcp.Required(), ), mcp.WithString("destination", mcp.Description("Destination path"), mcp.Required(), ), ), h.HandleMoveFile) s.AddTool(mcp.NewTool( "search_files", mcp.WithDescription("Recursively search for files and directories matching a pattern."), mcp.WithString("path", mcp.Description("Starting path for the search"), mcp.Required(), ), mcp.WithString("pattern", mcp.Description("Search pattern to match against file names"), mcp.Required(), ), ), h.HandleSearchFiles) s.AddTool(mcp.NewTool( "get_file_info", mcp.WithDescription("Retrieve detailed metadata about a file or directory."), mcp.WithString("path", mcp.Description("Path to the file or directory"), mcp.Required(), ), ), h.HandleGetFileInfo) s.AddTool(mcp.NewTool( "list_allowed_directories", mcp.WithDescription("Returns the list of directories that this server is allowed to access."), ), h.HandleListAllowedDirectories) s.AddTool(mcp.NewTool( "read_multiple_files", mcp.WithDescription("Read the contents of multiple files in a single operation."), mcp.WithArray("paths", mcp.Description("List of file paths to read"), mcp.Required(), mcp.Items(map[string]any{"type": "string"}), ), ), h.HandleReadMultipleFiles) s.AddTool(mcp.NewTool( "tree", mcp.WithDescription("Returns a hierarchical JSON representation of a directory structure."), mcp.WithString("path", mcp.Description("Path of the directory to traverse"), mcp.Required(), ), mcp.WithNumber("depth", mcp.Description("Maximum depth to traverse (default: 3)"), ), mcp.WithBoolean("follow_symlinks", mcp.Description("Whether to follow symbolic links (default: false)"), ), ), h.HandleTree) s.AddTool(mcp.NewTool( "delete_file", mcp.WithDescription("Delete a file or directory from the file system."), mcp.WithString("path", mcp.Description("Path to the file or directory to delete"), mcp.Required(), ), mcp.WithBoolean("recursive", mcp.Description("Whether to recursively delete directories (default: false)"), ), ), h.HandleDeleteFile) s.AddTool(mcp.NewTool( "modify_file", mcp.WithDescription("Update file by finding and replacing text. Provides a simple pattern matching interface without needing exact character positions."), mcp.WithString("path", mcp.Description("Path to the file to modify"), mcp.Required(), ), mcp.WithString("find", mcp.Description("Text to search for (exact match or regex pattern)"), mcp.Required(), ), mcp.WithString("replace", mcp.Description("Text to replace with"), mcp.Required(), ), mcp.WithBoolean("all_occurrences", mcp.Description("Replace all occurrences of the matching text (default: true)"), ), mcp.WithBoolean("regex", mcp.Description("Treat the find pattern as a regular expression (default: false)"), ), ), h.HandleModifyFile) s.AddTool(mcp.NewTool( "search_within_files", mcp.WithDescription("Search for text within file contents. Unlike search_files which only searches file names, this tool scans the actual contents of text files for matching substrings. Binary files are automatically excluded from the search. Reports file paths and line numbers where matches are found."), mcp.WithString("path", mcp.Description("Starting path for the search (must be a directory)"), mcp.Required(), ), mcp.WithString("substring", mcp.Description("Text to search for within file contents"), mcp.Required(), ), mcp.WithNumber("depth", mcp.Description("Maximum directory depth to search (default: unlimited)"), ), mcp.WithNumber("max_results", mcp.Description("Maximum number of results to return (default: 1000)"), ), ), h.HandleSearchWithinFiles) return s, nil } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/tree_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandleTree(t *testing.T) { // Setup a temporary directory for the test tmpDir := t.TempDir() // Create a handler with the temp dir as an allowed path allowedDirs := resolveAllowedDirs(t, tmpDir) fsHandler, err := NewFilesystemHandler(allowedDirs) require.NoError(t, err) ctx := context.Background() // Create test directory structure // /tmpDir/ // ├── file1.txt // ├── subdir1/ // │ ├── file2.txt // │ └── subdir2/ // │ └── file3.txt // └── emptydir/ file1Path := filepath.Join(tmpDir, "file1.txt") err = os.WriteFile(file1Path, []byte("content1"), 0644) require.NoError(t, err) subdir1Path := filepath.Join(tmpDir, "subdir1") err = os.Mkdir(subdir1Path, 0755) require.NoError(t, err) file2Path := filepath.Join(subdir1Path, "file2.txt") err = os.WriteFile(file2Path, []byte("content2"), 0644) require.NoError(t, err) subdir2Path := filepath.Join(subdir1Path, "subdir2") err = os.Mkdir(subdir2Path, 0755) require.NoError(t, err) file3Path := filepath.Join(subdir2Path, "file3.txt") err = os.WriteFile(file3Path, []byte("content3"), 0644) require.NoError(t, err) emptydirPath := filepath.Join(tmpDir, "emptydir") err = os.Mkdir(emptydirPath, 0755) require.NoError(t, err) t.Run("tree with default depth", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": tmpDir, }, }, } res, err := fsHandler.HandleTree(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains tree structure require.Len(t, res.Content, 2) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Directory tree for") assert.Contains(t, textContent.Text, "max depth: 3") // Parse the JSON to verify structure lines := textContent.Text assert.Contains(t, lines, "file1.txt") assert.Contains(t, lines, "subdir1") assert.Contains(t, lines, "file2.txt") assert.Contains(t, lines, "subdir2") assert.Contains(t, lines, "file3.txt") assert.Contains(t, lines, "emptydir") // Verify embedded resource embeddedResource := res.Content[1].(mcp.EmbeddedResource) assert.Equal(t, "resource", embeddedResource.Type) assert.Equal(t, "application/json", embeddedResource.Resource.(mcp.TextResourceContents).MIMEType) }) t.Run("tree with custom depth", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": tmpDir, "depth": 2.0, // Only go 2 levels deep }, }, } res, err := fsHandler.HandleTree(ctx, req) require.NoError(t, err) require.False(t, res.IsError) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "max depth: 2") // Should include file1.txt, subdir1, file2.txt, subdir2, emptydir // but NOT file3.txt (which is at depth 3) assert.Contains(t, textContent.Text, "file1.txt") assert.Contains(t, textContent.Text, "subdir1") assert.Contains(t, textContent.Text, "file2.txt") assert.Contains(t, textContent.Text, "subdir2") assert.Contains(t, textContent.Text, "emptydir") // file3.txt should not be included at depth 2 assert.NotContains(t, textContent.Text, "file3.txt") }) t.Run("tree with depth 1", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": tmpDir, "depth": 1.0, // Only show immediate children }, }, } res, err := fsHandler.HandleTree(ctx, req) require.NoError(t, err) require.False(t, res.IsError) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "max depth: 1") // Should only include immediate children assert.Contains(t, textContent.Text, "file1.txt") assert.Contains(t, textContent.Text, "subdir1") assert.Contains(t, textContent.Text, "emptydir") // Should not include nested files assert.NotContains(t, textContent.Text, "file2.txt") assert.NotContains(t, textContent.Text, "subdir2") assert.NotContains(t, textContent.Text, "file3.txt") }) t.Run("tree of empty directory", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": emptydirPath, }, }, } res, err := fsHandler.HandleTree(ctx, req) require.NoError(t, err) require.False(t, res.IsError) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Directory tree for") // Parse JSON to verify it's a directory with no children jsonStart := textContent.Text[strings.Index(textContent.Text, "{"):] var tree FileNode err = json.Unmarshal([]byte(jsonStart), &tree) require.NoError(t, err) assert.Equal(t, "directory", tree.Type) assert.Equal(t, "emptydir", tree.Name) assert.Nil(t, tree.Children) }) t.Run("try to tree a file instead of directory", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": file1Path, }, }, } res, err := fsHandler.HandleTree(ctx, req) require.NoError(t, err) require.True(t, res.IsError) require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "not a directory") }) t.Run("try to tree non-existent directory", func(t *testing.T) { nonExistentPath := filepath.Join(tmpDir, "non_existent_directory") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": nonExistentPath, }, }, } res, err := fsHandler.HandleTree(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) t.Run("path is in a non-allowed directory", func(t *testing.T) { otherDir := t.TempDir() req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "path": otherDir, }, }, } res, err := fsHandler.HandleTree(ctx, req) require.NoError(t, err) require.True(t, res.IsError) }) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/read_multiple_files_test.go: -------------------------------------------------------------------------------- ```go package handler import ( "context" "os" "path/filepath" "strings" "testing" "github.com/mark3labs/mcp-go/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestHandleReadMultipleFiles(t *testing.T) { // Setup a temporary directory for the test tmpDir := t.TempDir() // Create a handler with the temp dir as an allowed path allowedDirs := resolveAllowedDirs(t, tmpDir) fsHandler, err := NewFilesystemHandler(allowedDirs) require.NoError(t, err) ctx := context.Background() // Create test files file1Path := filepath.Join(tmpDir, "file1.txt") file1Content := "This is the content of file 1" err = os.WriteFile(file1Path, []byte(file1Content), 0644) require.NoError(t, err) file2Path := filepath.Join(tmpDir, "file2.txt") file2Content := "This is the content of file 2" err = os.WriteFile(file2Path, []byte(file2Content), 0644) require.NoError(t, err) // Create a directory dirPath := filepath.Join(tmpDir, "test_directory") err = os.Mkdir(dirPath, 0755) require.NoError(t, err) t.Run("read multiple text files", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "paths": []string{file1Path, file2Path}, }, }, } res, err := fsHandler.HandleReadMultipleFiles(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains content from both files require.GreaterOrEqual(t, len(res.Content), 4) // At least 2 headers + 2 content blocks // Convert all content to strings for easier checking var contentTexts []string for _, content := range res.Content { if textContent, ok := content.(mcp.TextContent); ok { contentTexts = append(contentTexts, textContent.Text) } } allText := strings.Join(contentTexts, "\n") assert.Contains(t, allText, "--- File: "+file1Path+" ---") assert.Contains(t, allText, "--- File: "+file2Path+" ---") assert.Contains(t, allText, file1Content) assert.Contains(t, allText, file2Content) }) t.Run("read single file", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "paths": []string{file1Path}, }, }, } res, err := fsHandler.HandleReadMultipleFiles(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Verify the response contains content from the file require.GreaterOrEqual(t, len(res.Content), 2) // At least 1 header + 1 content block var contentTexts []string for _, content := range res.Content { if textContent, ok := content.(mcp.TextContent); ok { contentTexts = append(contentTexts, textContent.Text) } } allText := strings.Join(contentTexts, "\n") assert.Contains(t, allText, "--- File: "+file1Path+" ---") assert.Contains(t, allText, file1Content) }) t.Run("try to read a directory", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "paths": []string{dirPath}, }, }, } res, err := fsHandler.HandleReadMultipleFiles(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Should get a message about it being a directory require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "is a directory") assert.Contains(t, textContent.Text, "Use list_directory tool") }) t.Run("try to read non-existent file", func(t *testing.T) { nonExistentPath := filepath.Join(tmpDir, "non_existent.txt") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "paths": []string{nonExistentPath}, }, }, } res, err := fsHandler.HandleReadMultipleFiles(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // The operation succeeds but individual files may have errors // Should get an error message about the file not existing require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Error accessing") assert.Contains(t, textContent.Text, nonExistentPath) }) t.Run("mix of valid and invalid files", func(t *testing.T) { nonExistentPath := filepath.Join(tmpDir, "non_existent.txt") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "paths": []string{file1Path, nonExistentPath, file2Path}, }, }, } res, err := fsHandler.HandleReadMultipleFiles(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // Should have content for valid files and error messages for invalid ones require.GreaterOrEqual(t, len(res.Content), 5) // At least 2 headers + 2 content blocks + 1 error var contentTexts []string for _, content := range res.Content { if textContent, ok := content.(mcp.TextContent); ok { contentTexts = append(contentTexts, textContent.Text) } } allText := strings.Join(contentTexts, "\n") assert.Contains(t, allText, "--- File: "+file1Path+" ---") assert.Contains(t, allText, "--- File: "+file2Path+" ---") assert.Contains(t, allText, file1Content) assert.Contains(t, allText, file2Content) assert.Contains(t, allText, "Error accessing") assert.Contains(t, allText, nonExistentPath) }) t.Run("no files specified", func(t *testing.T) { req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "paths": []string{}, }, }, } res, err := fsHandler.HandleReadMultipleFiles(ctx, req) require.NoError(t, err) require.True(t, res.IsError) require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "No files specified to read") }) t.Run("too many files", func(t *testing.T) { // Create a slice with more than 50 files (the maximum) var manyPaths []string for i := 0; i < 51; i++ { manyPaths = append(manyPaths, filepath.Join(tmpDir, "file.txt")) } req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "paths": manyPaths, }, }, } res, err := fsHandler.HandleReadMultipleFiles(ctx, req) require.NoError(t, err) require.True(t, res.IsError) require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Too many files requested") assert.Contains(t, textContent.Text, "Maximum is 50") }) t.Run("path in non-allowed directory", func(t *testing.T) { otherDir := t.TempDir() otherFile := filepath.Join(otherDir, "other.txt") req := mcp.CallToolRequest{ Params: mcp.CallToolParams{ Arguments: map[string]interface{}{ "paths": []string{otherFile}, }, }, } res, err := fsHandler.HandleReadMultipleFiles(ctx, req) require.NoError(t, err) require.False(t, res.IsError) // The operation succeeds but individual files may have errors require.Len(t, res.Content, 1) textContent := res.Content[0].(mcp.TextContent) assert.Contains(t, textContent.Text, "Error with path") assert.Contains(t, textContent.Text, otherFile) }) } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/search_within_files.go: -------------------------------------------------------------------------------- ```go package handler import ( "bufio" "context" "fmt" "os" "path/filepath" "strings" "github.com/mark3labs/mcp-go/mcp" ) func (fs *FilesystemHandler) HandleSearchWithinFiles( ctx context.Context, request mcp.CallToolRequest, ) (*mcp.CallToolResult, error) { // Extract and validate parameters path, err := request.RequireString("path") if err != nil { return nil, err } substring, err := request.RequireString("substring") if err != nil { return nil, err } if substring == "" { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: substring cannot be empty", }, }, IsError: true, }, nil } // Extract optional depth parameter maxDepth := 0 // 0 means unlimited if depthArg, err := request.RequireFloat("depth"); err == nil { maxDepth = int(depthArg) if maxDepth < 0 { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: depth cannot be negative", }, }, IsError: true, }, nil } } // Extract optional max_results parameter maxResults := MAX_SEARCH_RESULTS // default limit if maxResultsArg, err := request.RequireFloat("max_results"); err == nil { maxResults = int(maxResultsArg) if maxResults <= 0 { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: max_results must be positive", }, }, IsError: true, }, nil } } // Handle empty or relative paths like "." or "./" by converting to absolute path if path == "." || path == "./" { // Get current working directory cwd, err := os.Getwd() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error resolving current directory: %v", err), }, }, IsError: true, }, nil } path = cwd } validPath, err := fs.validatePath(path) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } // Check if the path is a directory info, err := os.Stat(validPath) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error: %v", err), }, }, IsError: true, }, nil } if !info.IsDir() { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: "Error: search path must be a directory", }, }, IsError: true, }, nil } // Perform the search results, err := searchWithinFiles(validPath, substring, maxDepth, maxResults, fs) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("Error searching within files: %v", err), }, }, IsError: true, }, nil } if len(results) == 0 { return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: fmt.Sprintf("No occurrences of '%s' found in files under %s", substring, path), }, }, }, nil } // Format search results var formattedResults strings.Builder formattedResults.WriteString(fmt.Sprintf("Found %d occurrences of '%s':\n\n", len(results), substring)) // Group results by file for easier readability fileResultsMap := make(map[string][]SearchResult) for _, result := range results { fileResultsMap[result.FilePath] = append(fileResultsMap[result.FilePath], result) } // Display results grouped by file for filePath, fileResults := range fileResultsMap { resourceURI := pathToResourceURI(filePath) formattedResults.WriteString(fmt.Sprintf("File: %s (%s)\n", filePath, resourceURI)) for _, result := range fileResults { // Truncate line content if too long (keeping context around the match) lineContent := result.LineContent if len(lineContent) > 100 { // Find the substring position substrPos := strings.Index(strings.ToLower(lineContent), strings.ToLower(substring)) // Calculate start and end positions for context contextStart := max(0, substrPos-30) contextEnd := min(len(lineContent), substrPos+len(substring)+30) if contextStart > 0 { lineContent = "..." + lineContent[contextStart:contextEnd] } else { lineContent = lineContent[:contextEnd] } if contextEnd < len(result.LineContent) { lineContent += "..." } } formattedResults.WriteString(fmt.Sprintf(" Line %d: %s\n", result.LineNumber, lineContent)) } formattedResults.WriteString("\n") } // If results were limited, note this in the output if len(results) >= maxResults { formattedResults.WriteString(fmt.Sprintf("\nNote: Results limited to %d matches. There may be more occurrences.", maxResults)) } return &mcp.CallToolResult{ Content: []mcp.Content{ mcp.TextContent{ Type: "text", Text: formattedResults.String(), }, }, }, nil } // searchWithinFiles searches for a substring within file contents func searchWithinFiles( rootPath, substring string, maxDepth int, maxResults int, fs *FilesystemHandler, ) ([]SearchResult, error) { var results []SearchResult resultCount := 0 currentDepth := 0 // Walk the directory tree err := filepath.Walk( rootPath, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // Skip errors and continue } // Check if we've reached the maximum number of results if resultCount >= maxResults { return filepath.SkipDir } // Try to validate path validPath, err := fs.validatePath(path) if err != nil { return nil // Skip invalid paths } // Skip directories, only search files if info.IsDir() { // Calculate depth for this directory relPath, err := filepath.Rel(rootPath, path) if err != nil { return nil // Skip on error } // Count separators to determine depth (empty or "." means we're at rootPath) if relPath == "" || relPath == "." { currentDepth = 0 } else { currentDepth = strings.Count(relPath, string(filepath.Separator)) + 1 } // Skip directories beyond max depth if specified if maxDepth > 0 && currentDepth >= maxDepth { return filepath.SkipDir } return nil } // Skip files that are too large if info.Size() > MAX_SEARCHABLE_SIZE { return nil } // Determine MIME type and skip non-text files mimeType := detectMimeType(validPath) if !isTextFile(mimeType) { return nil } // Open the file and search for the substring file, err := os.Open(validPath) if err != nil { return nil // Skip files that can't be opened } defer file.Close() // Create a scanner to read the file line by line scanner := bufio.NewScanner(file) lineNum := 0 // Scan each line for scanner.Scan() { lineNum++ line := scanner.Text() // Check if the line contains the substring if strings.Contains(line, substring) { // Add to results results = append(results, SearchResult{ FilePath: validPath, LineNumber: lineNum, LineContent: line, ResourceURI: pathToResourceURI(validPath), }) resultCount++ // Check if we've reached the maximum results if resultCount >= maxResults { return filepath.SkipDir } } } // Check for scanner errors if err := scanner.Err(); err != nil { return nil // Skip files with scanning errors } return nil }, ) if err != nil { return nil, err } return results, nil } // Helper function since Go < 1.21 doesn't have min/max functions func min(a, b int) int { if a < b { return a } return b } // Helper function since Go < 1.21 doesn't have min/max functions func max(a, b int) int { if a > b { return a } return b } ```