# 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: -------------------------------------------------------------------------------- ``` 1 | .aider* 2 | .opencode* 3 | OpenCode.md 4 | dist/ 5 | CLAUDE.md 6 | .claude/ 7 | 8 | # go build artifact 9 | mcp-filesystem-server 10 | ``` -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- ```yaml 1 | version: 2 2 | 3 | before: 4 | hooks: 5 | - go mod tidy 6 | 7 | builds: 8 | - id: mcp-filesystem-server 9 | env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | goarch: 16 | - amd64 17 | - arm64 18 | ldflags: 19 | - -s -w -X github.com/mark3labs/mcp-filesystem-server/filesystemserver.Version={{.Version}} 20 | binary: mcp-filesystem-server 21 | main: . 22 | 23 | archives: 24 | - id: default 25 | format_overrides: 26 | - goos: windows 27 | formats: 28 | - zip 29 | name_template: >- 30 | {{ .ProjectName }}_ 31 | {{- .Os }}_ 32 | {{- .Arch }} 33 | files: 34 | - README.md 35 | - LICENSE* 36 | 37 | checksum: 38 | name_template: 'checksums.txt' 39 | algorithm: sha256 40 | 41 | # Using new snapshot configuration 42 | snapshot: 43 | version_template: "{{ .Version }}-SNAPSHOT-{{ .ShortCommit }}" 44 | 45 | changelog: 46 | sort: asc 47 | filters: 48 | exclude: 49 | - '^docs:' 50 | - '^test:' 51 | - Merge pull request 52 | - Merge branch 53 | 54 | release: 55 | github: 56 | owner: mark3labs 57 | name: mcp-filesystem-server 58 | draft: false 59 | prerelease: auto 60 | name_template: "{{ .Tag }}" 61 | mode: replace 62 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP Filesystem Server 2 | 3 | This MCP server provides secure access to the local filesystem via the Model Context Protocol (MCP). 4 | 5 | ## Components 6 | 7 | ### Resources 8 | 9 | - **file://** 10 | - Name: File System 11 | - Description: Access to files and directories on the local file system 12 | 13 | ### Tools 14 | 15 | #### File Operations 16 | 17 | - **read_file** 18 | - Read the complete contents of a file from the file system 19 | - Parameters: `path` (required): Path to the file to read 20 | 21 | - **read_multiple_files** 22 | - Read the contents of multiple files in a single operation 23 | - Parameters: `paths` (required): List of file paths to read 24 | 25 | - **write_file** 26 | - Create a new file or overwrite an existing file with new content 27 | - Parameters: `path` (required): Path where to write the file, `content` (required): Content to write to the file 28 | 29 | - **copy_file** 30 | - Copy files and directories 31 | - Parameters: `source` (required): Source path of the file or directory, `destination` (required): Destination path 32 | 33 | - **move_file** 34 | - Move or rename files and directories 35 | - Parameters: `source` (required): Source path of the file or directory, `destination` (required): Destination path 36 | 37 | - **delete_file** 38 | - Delete a file or directory from the file system 39 | - Parameters: `path` (required): Path to the file or directory to delete, `recursive` (optional): Whether to recursively delete directories (default: false) 40 | 41 | - **modify_file** 42 | - Update file by finding and replacing text using string matching or regex 43 | - 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) 44 | 45 | #### Directory Operations 46 | 47 | - **list_directory** 48 | - Get a detailed listing of all files and directories in a specified path 49 | - Parameters: `path` (required): Path of the directory to list 50 | 51 | - **create_directory** 52 | - Create a new directory or ensure a directory exists 53 | - Parameters: `path` (required): Path of the directory to create 54 | 55 | - **tree** 56 | - Returns a hierarchical JSON representation of a directory structure 57 | - 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) 58 | 59 | #### Search and Information 60 | 61 | - **search_files** 62 | - Recursively search for files and directories matching a pattern 63 | - Parameters: `path` (required): Starting path for the search, `pattern` (required): Search pattern to match against file names 64 | 65 | - **search_within_files** 66 | - Search for text within file contents across directory trees 67 | - 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) 68 | 69 | - **get_file_info** 70 | - Retrieve detailed metadata about a file or directory 71 | - Parameters: `path` (required): Path to the file or directory 72 | 73 | - **list_allowed_directories** 74 | - Returns the list of directories that this server is allowed to access 75 | - Parameters: None 76 | 77 | ## Features 78 | 79 | - Secure access to specified directories 80 | - Path validation to prevent directory traversal attacks 81 | - Symlink resolution with security checks 82 | - MIME type detection 83 | - Support for text, binary, and image files 84 | - Size limits for inline content and base64 encoding 85 | 86 | ## Getting Started 87 | 88 | ### Installation 89 | 90 | #### Using Go Install 91 | 92 | ```bash 93 | go install github.com/mark3labs/mcp-filesystem-server@latest 94 | ``` 95 | 96 | ### Usage 97 | 98 | #### As a standalone server 99 | 100 | Start the MCP server with allowed directories: 101 | 102 | ```bash 103 | mcp-filesystem-server /path/to/allowed/directory [/another/allowed/directory ...] 104 | ``` 105 | 106 | #### As a library in your Go project 107 | 108 | ```go 109 | package main 110 | 111 | import ( 112 | "log" 113 | "os" 114 | 115 | "github.com/mark3labs/mcp-filesystem-server/filesystemserver" 116 | ) 117 | 118 | func main() { 119 | // Create a new filesystem server with allowed directories 120 | allowedDirs := []string{"/path/to/allowed/directory", "/another/allowed/directory"} 121 | fs, err := filesystemserver.NewFilesystemServer(allowedDirs) 122 | if err != nil { 123 | log.Fatalf("Failed to create server: %v", err) 124 | } 125 | 126 | // Serve requests 127 | if err := fs.Serve(); err != nil { 128 | log.Fatalf("Server error: %v", err) 129 | } 130 | } 131 | ``` 132 | 133 | ### Usage with Model Context Protocol 134 | 135 | To integrate this server with apps that support MCP: 136 | 137 | ```json 138 | { 139 | "mcpServers": { 140 | "filesystem": { 141 | "command": "mcp-filesystem-server", 142 | "args": ["/path/to/allowed/directory", "/another/allowed/directory"] 143 | } 144 | } 145 | } 146 | ``` 147 | 148 | ### Docker 149 | 150 | #### Running with Docker 151 | 152 | You can run the Filesystem MCP server using Docker: 153 | 154 | ```bash 155 | docker run -i --rm ghcr.io/mark3labs/mcp-filesystem-server:latest /path/to/allowed/directory 156 | ``` 157 | 158 | #### Docker Configuration with MCP 159 | 160 | To integrate the Docker image with apps that support MCP: 161 | 162 | ```json 163 | { 164 | "mcpServers": { 165 | "filesystem": { 166 | "command": "docker", 167 | "args": [ 168 | "run", 169 | "-i", 170 | "--rm", 171 | "ghcr.io/mark3labs/mcp-filesystem-server:latest", 172 | "/path/to/allowed/directory" 173 | ] 174 | } 175 | } 176 | } 177 | ``` 178 | 179 | 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: 180 | 181 | ```json 182 | { 183 | "mcpServers": { 184 | "filesystem": { 185 | "command": "docker", 186 | "args": [ 187 | "run", 188 | "-i", 189 | "--rm", 190 | "--volume=/allowed/directory/in/host:/allowed/directory/in/container", 191 | "ghcr.io/mark3labs/mcp-filesystem-server:latest", 192 | "/allowed/directory/in/container" 193 | ] 194 | } 195 | } 196 | } 197 | ``` 198 | 199 | ## License 200 | 201 | See the [LICENSE](LICENSE) file for details. 202 | ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- ```yaml 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | groups: 8 | minor-dependencies: 9 | update-types: 10 | - "minor" 11 | - "patch" 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | ``` -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | tests: 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest, windows-latest, macos-latest] 10 | runs-on: ${{ matrix.os }} 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | with: 15 | fetch-depth: 0 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: '>=1.21.0' 21 | check-latest: true 22 | 23 | - name: Run tests 24 | run: go test -race ./... 25 | ``` -------------------------------------------------------------------------------- /filesystemserver/inprocess_test.go: -------------------------------------------------------------------------------- ```go 1 | package filesystemserver_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mark3labs/mcp-filesystem-server/filesystemserver" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestInProcess(t *testing.T) { 12 | fss, err := filesystemserver.NewFilesystemServer([]string{"."}) 13 | require.NoError(t, err) 14 | 15 | mcpClient := startTestClient(t, fss) 16 | 17 | // just check for a specific tool 18 | tool := getTool(t, mcpClient, "read_file") 19 | assert.NotNil(t, tool, "read_file tool not found in the list of tools") 20 | } 21 | ``` -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- ```go 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/mark3labs/mcp-filesystem-server/filesystemserver" 9 | "github.com/mark3labs/mcp-go/server" 10 | ) 11 | 12 | func main() { 13 | // Parse command line arguments 14 | if len(os.Args) < 2 { 15 | fmt.Fprintf( 16 | os.Stderr, 17 | "Usage: %s <allowed-directory> [additional-directories...]\n", 18 | os.Args[0], 19 | ) 20 | os.Exit(1) 21 | } 22 | 23 | // Create and start the server 24 | fss, err := filesystemserver.NewFilesystemServer(os.Args[1:]) 25 | if err != nil { 26 | log.Fatalf("Failed to create server: %v", err) 27 | } 28 | 29 | // Serve requests 30 | if err := server.ServeStdio(fss); err != nil { 31 | log.Fatalf("Server error: %v", err) 32 | } 33 | } 34 | ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM golang:1.23-alpine AS builder 3 | 4 | WORKDIR /app 5 | 6 | # Copy go.mod and go.sum first for caching dependencies 7 | COPY go.mod go.sum ./ 8 | 9 | # Download dependencies 10 | RUN go mod download 11 | 12 | # Copy the source code 13 | COPY . . 14 | 15 | # Build the application 16 | RUN go build -ldflags="-s -w" -o server . 17 | 18 | FROM alpine:latest 19 | 20 | WORKDIR /app 21 | 22 | # Copy the built binary from the builder stage 23 | COPY --from=builder /app/server ./ 24 | 25 | # The container will by default pass '/app' as the allowed directory if no other command line arguments are provided 26 | ENTRYPOINT ["./server"] 27 | CMD ["/app"] 28 | ``` -------------------------------------------------------------------------------- /filesystemserver/server_test.go: -------------------------------------------------------------------------------- ```go 1 | package filesystemserver_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/mark3labs/mcp-filesystem-server/filesystemserver" 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | // regression test for invalid schema => missing items in array definition 12 | func TestReadMultipleFilesSchema(t *testing.T) { 13 | fsserver, err := filesystemserver.NewFilesystemServer([]string{t.TempDir()}) 14 | require.NoError(t, err) 15 | 16 | mcpClient := startTestClient(t, fsserver) 17 | 18 | tool := getTool(t, mcpClient, "read_multiple_files") 19 | require.NotNil(t, tool) 20 | 21 | // make sure that the tool has the correct schema 22 | paths, ok := tool.InputSchema.Properties["paths"] 23 | assert.True(t, ok) 24 | pathsMap, ok := paths.(map[string]any) 25 | assert.True(t, ok) 26 | _, ok = pathsMap["items"] 27 | assert.True(t, ok) 28 | } 29 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/handler_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | // resolveAllowedDirs generates a list of allowed paths, including their resolved symlinks. 11 | // This ensures both the original paths and their symlink-resolved counterparts are included, 12 | // which is useful when paths may be symlinks (e.g., t.TempDir() on some Unix systems). 13 | func resolveAllowedDirs(t *testing.T, dirs ...string) []string { 14 | t.Helper() 15 | allowedDirs := make([]string, 0) 16 | for _, dir := range dirs { 17 | allowedDirs = append(allowedDirs, dir) 18 | 19 | resolvedPath, err := filepath.EvalSymlinks(dir) 20 | require.NoError(t, err, "Failed to resolve symlinks for directory: %s", dir) 21 | 22 | if resolvedPath != dir { 23 | allowedDirs = append(allowedDirs, resolvedPath) 24 | } 25 | } 26 | return allowedDirs 27 | } 28 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/list_allowed_directories.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | func (fs *FilesystemHandler) HandleListAllowedDirectories( 13 | ctx context.Context, 14 | request mcp.CallToolRequest, 15 | ) (*mcp.CallToolResult, error) { 16 | // Remove the trailing separator for display purposes 17 | displayDirs := make([]string, len(fs.allowedDirs)) 18 | for i, dir := range fs.allowedDirs { 19 | displayDirs[i] = strings.TrimSuffix(dir, string(filepath.Separator)) 20 | } 21 | 22 | var result strings.Builder 23 | result.WriteString("Allowed directories:\n\n") 24 | 25 | for _, dir := range displayDirs { 26 | resourceURI := pathToResourceURI(dir) 27 | result.WriteString(fmt.Sprintf("%s (%s)\n", dir, resourceURI)) 28 | } 29 | 30 | return &mcp.CallToolResult{ 31 | Content: []mcp.Content{ 32 | mcp.TextContent{ 33 | Type: "text", 34 | Text: result.String(), 35 | }, 36 | }, 37 | }, nil 38 | } ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | required: 9 | - allowedDirectory 10 | properties: 11 | allowedDirectory: 12 | type: string 13 | description: The absolute path to an allowed directory for the filesystem 14 | server. For example, in the Docker container '/app' is a good default. 15 | additionalDirectories: 16 | type: array 17 | items: 18 | type: string 19 | description: Optional additional allowed directories. 20 | commandFunction: 21 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 22 | |- 23 | (config) => { const args = [config.allowedDirectory]; if (config.additionalDirectories && Array.isArray(config.additionalDirectories)) { args.push(...config.additionalDirectories); } return { command: './server', args: args }; } 24 | exampleConfig: 25 | allowedDirectory: /app 26 | additionalDirectories: [] 27 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/handler.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | ) 8 | 9 | type FilesystemHandler struct { 10 | allowedDirs []string 11 | } 12 | 13 | func NewFilesystemHandler(allowedDirs []string) (*FilesystemHandler, error) { 14 | // Normalize and validate directories 15 | normalized := make([]string, 0, len(allowedDirs)) 16 | for _, dir := range allowedDirs { 17 | abs, err := filepath.Abs(dir) 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to resolve path %s: %w", dir, err) 20 | } 21 | 22 | info, err := os.Stat(abs) 23 | if err != nil { 24 | return nil, fmt.Errorf( 25 | "failed to access directory %s: %w", 26 | abs, 27 | err, 28 | ) 29 | } 30 | if !info.IsDir() { 31 | return nil, fmt.Errorf("path is not a directory: %s", abs) 32 | } 33 | 34 | // Ensure the path ends with a separator to prevent prefix matching issues 35 | // For example, /tmp/foo should not match /tmp/foobar 36 | normalized = append(normalized, filepath.Clean(abs)+string(filepath.Separator)) 37 | } 38 | return &FilesystemHandler{ 39 | allowedDirs: normalized, 40 | }, nil 41 | } 42 | 43 | // pathToResourceURI converts a file path to a resource URI 44 | func pathToResourceURI(path string) string { 45 | return "file://" + path 46 | } 47 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/types.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import "time" 4 | 5 | const ( 6 | // Maximum size for inline content (5MB) 7 | MAX_INLINE_SIZE = 5 * 1024 * 1024 8 | // Maximum size for base64 encoding (1MB) 9 | MAX_BASE64_SIZE = 1 * 1024 * 1024 10 | // Maximum number of search results to return (prevent excessive output) 11 | MAX_SEARCH_RESULTS = 1000 12 | // Maximum file size in bytes to search within (10MB) 13 | MAX_SEARCHABLE_SIZE = 10 * 1024 * 1024 14 | ) 15 | 16 | type FileInfo struct { 17 | Size int64 `json:"size"` 18 | Created time.Time `json:"created"` 19 | Modified time.Time `json:"modified"` 20 | Accessed time.Time `json:"accessed"` 21 | IsDirectory bool `json:"isDirectory"` 22 | IsFile bool `json:"isFile"` 23 | Permissions string `json:"permissions"` 24 | } 25 | 26 | // FileNode represents a node in the file tree 27 | type FileNode struct { 28 | Name string `json:"name"` 29 | Path string `json:"path"` 30 | Type string `json:"type"` // "file" or "directory" 31 | Size int64 `json:"size,omitempty"` 32 | Modified time.Time `json:"modified,omitempty"` 33 | Children []*FileNode `json:"children,omitempty"` 34 | } 35 | 36 | // SearchResult represents a single match in a file 37 | type SearchResult struct { 38 | FilePath string 39 | LineNumber int 40 | LineContent string 41 | ResourceURI string 42 | } 43 | ``` -------------------------------------------------------------------------------- /filesystemserver/utils_test.go: -------------------------------------------------------------------------------- ```go 1 | package filesystemserver_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/mark3labs/mcp-filesystem-server/filesystemserver" 8 | "github.com/mark3labs/mcp-go/client" 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/mark3labs/mcp-go/server" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func startTestClient(t *testing.T, fss *server.MCPServer) client.MCPClient { 17 | t.Helper() 18 | 19 | mcpClient, err := client.NewInProcessClient(fss) 20 | require.NoError(t, err) 21 | t.Cleanup(func() { mcpClient.Close() }) 22 | 23 | err = mcpClient.Start(context.Background()) 24 | require.NoError(t, err) 25 | 26 | // Initialize the client 27 | initRequest := mcp.InitializeRequest{} 28 | initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION 29 | initRequest.Params.ClientInfo = mcp.Implementation{ 30 | Name: "test-client", 31 | Version: "1.0.0", 32 | } 33 | result, err := mcpClient.Initialize(context.Background(), initRequest) 34 | require.NoError(t, err) 35 | assert.Equal(t, "secure-filesystem-server", result.ServerInfo.Name) 36 | assert.Equal(t, filesystemserver.Version, result.ServerInfo.Version) 37 | 38 | return mcpClient 39 | } 40 | 41 | func getTool(t *testing.T, mcpClient client.MCPClient, toolName string) *mcp.Tool { 42 | result, err := mcpClient.ListTools(context.Background(), mcp.ListToolsRequest{}) 43 | require.NoError(t, err) 44 | for _, tool := range result.Tools { 45 | if tool.Name == toolName { 46 | return &tool 47 | } 48 | } 49 | require.Fail(t, "Tool not found", toolName) 50 | return nil 51 | } 52 | ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | goreleaser: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Set up Go 22 | uses: actions/setup-go@v5 23 | with: 24 | go-version: '>=1.21.0' 25 | check-latest: true 26 | 27 | - name: Run GoReleaser 28 | uses: goreleaser/goreleaser-action@v6 29 | with: 30 | distribution: goreleaser 31 | version: '~> v2' 32 | args: release --clean 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | 36 | - name: Extract version 37 | id: get-version 38 | run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT 39 | 40 | - name: Login to GitHub Container Registry 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ghcr.io 44 | username: ${{ github.repository_owner }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | 47 | - name: Set up Docker Buildx 48 | uses: docker/setup-buildx-action@v3 49 | 50 | - name: Build and push Docker image 51 | uses: docker/build-push-action@v6 52 | with: 53 | context: . 54 | push: true 55 | platforms: linux/amd64,linux/arm64 56 | tags: | 57 | ghcr.io/${{ github.repository }}:latest 58 | ghcr.io/${{ github.repository }}:${{ steps.get-version.outputs.VERSION }} 59 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/search_files_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestSearchFiles_Pattern(t *testing.T) { 15 | 16 | // setting up test folder 17 | // tmpDir/ 18 | // - foo/ 19 | // - bar.h 20 | // - test.c 21 | // - test.h 22 | // - test.c 23 | 24 | dir := t.TempDir() 25 | test_h := filepath.Join(dir, "test.h") 26 | err := os.WriteFile(test_h, []byte("foo"), 0644) 27 | require.NoError(t, err) 28 | 29 | test_c := filepath.Join(dir, "test.c") 30 | err = os.WriteFile(test_c, []byte("foo"), 0644) 31 | require.NoError(t, err) 32 | 33 | fooDir := filepath.Join(dir, "foo") 34 | err = os.MkdirAll(fooDir, 0755) 35 | require.NoError(t, err) 36 | 37 | foo_bar_h := filepath.Join(fooDir, "bar.h") 38 | err = os.WriteFile(foo_bar_h, []byte("foo"), 0644) 39 | require.NoError(t, err) 40 | 41 | foo_test_c := filepath.Join(fooDir, "test.c") 42 | err = os.WriteFile(foo_test_c, []byte("foo"), 0644) 43 | require.NoError(t, err) 44 | 45 | handler, err := NewFilesystemHandler(resolveAllowedDirs(t, dir)) 46 | require.NoError(t, err) 47 | 48 | tests := []struct { 49 | info string 50 | pattern string 51 | matches []string 52 | }{ 53 | {info: "use placeholder with extension", pattern: "*.h", matches: []string{test_h, foo_bar_h}}, 54 | {info: "use placeholder with name", pattern: "test.*", matches: []string{test_h, test_c}}, 55 | {info: "same filename", pattern: "test.c", matches: []string{test_c, foo_test_c}}, 56 | } 57 | 58 | for _, test := range tests { 59 | t.Run(test.info, func(t *testing.T) { 60 | request := mcp.CallToolRequest{} 61 | request.Params.Name = "search_files" 62 | request.Params.Arguments = map[string]any{ 63 | "path": dir, 64 | "pattern": test.pattern, 65 | } 66 | 67 | result, err := handler.HandleSearchFiles(context.Background(), request) 68 | require.NoError(t, err) 69 | assert.False(t, result.IsError) 70 | assert.Len(t, result.Content, 1) 71 | 72 | for _, match := range test.matches { 73 | assert.Contains(t, result.Content[0].(mcp.TextContent).Text, match) 74 | } 75 | }) 76 | } 77 | } 78 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/read_file_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | 10 | "github.com/mark3labs/mcp-go/mcp" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestReadfile_Valid(t *testing.T) { 16 | // prepare temp directory 17 | dir := t.TempDir() 18 | content := "test-content" 19 | err := os.WriteFile(filepath.Join(dir, "test"), []byte(content), 0644) 20 | require.NoError(t, err) 21 | 22 | handler, err := NewFilesystemHandler(resolveAllowedDirs(t, dir)) 23 | require.NoError(t, err) 24 | request := mcp.CallToolRequest{} 25 | request.Params.Name = "read_file" 26 | request.Params.Arguments = map[string]any{ 27 | "path": filepath.Join(dir, "test"), 28 | } 29 | 30 | result, err := handler.HandleReadFile(context.Background(), request) 31 | require.NoError(t, err) 32 | assert.Len(t, result.Content, 1) 33 | assert.Equal(t, content, result.Content[0].(mcp.TextContent).Text) 34 | } 35 | 36 | func TestReadfile_Invalid(t *testing.T) { 37 | dir := t.TempDir() 38 | handler, err := NewFilesystemHandler(resolveAllowedDirs(t, dir)) 39 | require.NoError(t, err) 40 | 41 | request := mcp.CallToolRequest{} 42 | request.Params.Name = "read_file" 43 | request.Params.Arguments = map[string]any{ 44 | "path": filepath.Join(dir, "test"), 45 | } 46 | 47 | result, err := handler.HandleReadFile(context.Background(), request) 48 | require.NoError(t, err) 49 | assert.True(t, result.IsError) 50 | assert.Contains(t, fmt.Sprint(result.Content[0]), "no such file or directory") 51 | } 52 | 53 | func TestReadfile_NoAccess(t *testing.T) { 54 | dir1 := t.TempDir() 55 | dir2 := t.TempDir() 56 | 57 | handler, err := NewFilesystemHandler(resolveAllowedDirs(t, dir1)) 58 | require.NoError(t, err) 59 | 60 | request := mcp.CallToolRequest{} 61 | request.Params.Name = "read_file" 62 | request.Params.Arguments = map[string]any{ 63 | "path": filepath.Join(dir2, "test"), 64 | } 65 | 66 | result, err := handler.HandleReadFile(context.Background(), request) 67 | require.NoError(t, err) 68 | assert.True(t, result.IsError) 69 | assert.Contains(t, fmt.Sprint(result.Content[0]), "access denied - path outside allowed directories") 70 | } 71 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/list_allowed_directories_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/mark3labs/mcp-go/mcp" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestHandleListAllowedDirectories(t *testing.T) { 13 | // Setup multiple temporary directories for the test 14 | tmpDir1 := t.TempDir() 15 | tmpDir2 := t.TempDir() 16 | 17 | // Create a handler with multiple allowed directories 18 | allowedDirs := resolveAllowedDirs(t, tmpDir1, tmpDir2) 19 | fsHandler, err := NewFilesystemHandler(allowedDirs) 20 | require.NoError(t, err) 21 | 22 | ctx := context.Background() 23 | 24 | t.Run("list allowed directories", func(t *testing.T) { 25 | req := mcp.CallToolRequest{ 26 | Params: mcp.CallToolParams{ 27 | Arguments: map[string]interface{}{}, 28 | }, 29 | } 30 | 31 | res, err := fsHandler.HandleListAllowedDirectories(ctx, req) 32 | require.NoError(t, err) 33 | require.False(t, res.IsError) 34 | 35 | // Verify the response contains the allowed directories 36 | require.Len(t, res.Content, 1) 37 | textContent := res.Content[0].(mcp.TextContent) 38 | assert.Contains(t, textContent.Text, "Allowed directories:") 39 | assert.Contains(t, textContent.Text, tmpDir1) 40 | assert.Contains(t, textContent.Text, tmpDir2) 41 | assert.Contains(t, textContent.Text, "file://") 42 | }) 43 | 44 | t.Run("single allowed directory", func(t *testing.T) { 45 | singleDir := t.TempDir() 46 | singleAllowedDirs := resolveAllowedDirs(t, singleDir) 47 | singleFsHandler, err := NewFilesystemHandler(singleAllowedDirs) 48 | require.NoError(t, err) 49 | 50 | req := mcp.CallToolRequest{ 51 | Params: mcp.CallToolParams{ 52 | Arguments: map[string]interface{}{}, 53 | }, 54 | } 55 | 56 | res, err := singleFsHandler.HandleListAllowedDirectories(ctx, req) 57 | require.NoError(t, err) 58 | require.False(t, res.IsError) 59 | 60 | // Verify the response contains the single allowed directory 61 | require.Len(t, res.Content, 1) 62 | textContent := res.Content[0].(mcp.TextContent) 63 | assert.Contains(t, textContent.Text, "Allowed directories:") 64 | assert.Contains(t, textContent.Text, singleDir) 65 | assert.Contains(t, textContent.Text, "file://") 66 | }) 67 | } 68 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/create_directory_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestHandleCreateDirectory(t *testing.T) { 15 | // Setup a temporary directory for the test 16 | tmpDir := t.TempDir() 17 | 18 | // Create a handler with the temp dir as an allowed path 19 | allowedDirs := resolveAllowedDirs(t, tmpDir) 20 | fsHandler, err := NewFilesystemHandler(allowedDirs) 21 | require.NoError(t, err) 22 | 23 | ctx := context.Background() 24 | 25 | t.Run("create a new directory", func(t *testing.T) { 26 | newDirPath := filepath.Join(tmpDir, "new_directory") 27 | req := mcp.CallToolRequest{ 28 | Params: mcp.CallToolParams{ 29 | Arguments: map[string]interface{}{ 30 | "path": newDirPath, 31 | }, 32 | }, 33 | } 34 | 35 | res, err := fsHandler.HandleCreateDirectory(ctx, req) 36 | require.NoError(t, err) 37 | require.False(t, res.IsError) 38 | 39 | // Verify the directory was created 40 | info, err := os.Stat(newDirPath) 41 | require.NoError(t, err) 42 | assert.True(t, info.IsDir()) 43 | }) 44 | 45 | t.Run("directory already exists", func(t *testing.T) { 46 | existingDirPath := filepath.Join(tmpDir, "existing_directory") 47 | err := os.Mkdir(existingDirPath, 0755) 48 | require.NoError(t, err) 49 | 50 | req := mcp.CallToolRequest{ 51 | Params: mcp.CallToolParams{ 52 | Arguments: map[string]interface{}{ 53 | "path": existingDirPath, 54 | }, 55 | }, 56 | } 57 | 58 | res, err := fsHandler.HandleCreateDirectory(ctx, req) 59 | require.NoError(t, err) 60 | require.False(t, res.IsError) // Should not be an error, just a message that it already exists 61 | }) 62 | 63 | t.Run("path exists but is not a directory", func(t *testing.T) { 64 | filePath := filepath.Join(tmpDir, "existing_file.txt") 65 | err := os.WriteFile(filePath, []byte("content"), 0644) 66 | require.NoError(t, err) 67 | 68 | req := mcp.CallToolRequest{ 69 | Params: mcp.CallToolParams{ 70 | Arguments: map[string]interface{}{ 71 | "path": filePath, 72 | }, 73 | }, 74 | } 75 | 76 | res, err := fsHandler.HandleCreateDirectory(ctx, req) 77 | require.NoError(t, err) 78 | require.True(t, res.IsError) 79 | }) 80 | 81 | t.Run("path is in a non-allowed directory", func(t *testing.T) { 82 | otherDir := t.TempDir() 83 | 84 | req := mcp.CallToolRequest{ 85 | Params: mcp.CallToolParams{ 86 | Arguments: map[string]interface{}{ 87 | "path": filepath.Join(otherDir, "new_directory"), 88 | }, 89 | }, 90 | } 91 | 92 | res, err := fsHandler.HandleCreateDirectory(ctx, req) 93 | require.NoError(t, err) 94 | require.True(t, res.IsError) 95 | }) 96 | } 97 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/create_directory.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | func (fs *FilesystemHandler) HandleCreateDirectory( 12 | ctx context.Context, 13 | request mcp.CallToolRequest, 14 | ) (*mcp.CallToolResult, error) { 15 | path, err := request.RequireString("path") 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | // Handle empty or relative paths like "." or "./" by converting to absolute path 21 | if path == "." || path == "./" { 22 | // Get current working directory 23 | cwd, err := os.Getwd() 24 | if err != nil { 25 | return &mcp.CallToolResult{ 26 | Content: []mcp.Content{ 27 | mcp.TextContent{ 28 | Type: "text", 29 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 30 | }, 31 | }, 32 | IsError: true, 33 | }, nil 34 | } 35 | path = cwd 36 | } 37 | 38 | validPath, err := fs.validatePath(path) 39 | if err != nil { 40 | return &mcp.CallToolResult{ 41 | Content: []mcp.Content{ 42 | mcp.TextContent{ 43 | Type: "text", 44 | Text: fmt.Sprintf("Error: %v", err), 45 | }, 46 | }, 47 | IsError: true, 48 | }, nil 49 | } 50 | 51 | // Check if path already exists 52 | if info, err := os.Stat(validPath); err == nil { 53 | if info.IsDir() { 54 | resourceURI := pathToResourceURI(validPath) 55 | return &mcp.CallToolResult{ 56 | Content: []mcp.Content{ 57 | mcp.TextContent{ 58 | Type: "text", 59 | Text: fmt.Sprintf("Directory already exists: %s", path), 60 | }, 61 | mcp.EmbeddedResource{ 62 | Type: "resource", 63 | Resource: mcp.TextResourceContents{ 64 | URI: resourceURI, 65 | MIMEType: "text/plain", 66 | Text: fmt.Sprintf("Directory: %s", validPath), 67 | }, 68 | }, 69 | }, 70 | }, nil 71 | } 72 | return &mcp.CallToolResult{ 73 | Content: []mcp.Content{ 74 | mcp.TextContent{ 75 | Type: "text", 76 | Text: fmt.Sprintf("Error: Path exists but is not a directory: %s", path), 77 | }, 78 | }, 79 | IsError: true, 80 | }, nil 81 | } 82 | 83 | if err := os.MkdirAll(validPath, 0755); err != nil { 84 | return &mcp.CallToolResult{ 85 | Content: []mcp.Content{ 86 | mcp.TextContent{ 87 | Type: "text", 88 | Text: fmt.Sprintf("Error creating directory: %v", err), 89 | }, 90 | }, 91 | IsError: true, 92 | }, nil 93 | } 94 | 95 | resourceURI := pathToResourceURI(validPath) 96 | return &mcp.CallToolResult{ 97 | Content: []mcp.Content{ 98 | mcp.TextContent{ 99 | Type: "text", 100 | Text: fmt.Sprintf("Successfully created directory %s", path), 101 | }, 102 | mcp.EmbeddedResource{ 103 | Type: "resource", 104 | Resource: mcp.TextResourceContents{ 105 | URI: resourceURI, 106 | MIMEType: "text/plain", 107 | Text: fmt.Sprintf("Directory: %s", validPath), 108 | }, 109 | }, 110 | }, 111 | }, nil 112 | } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/write_file.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | func (fs *FilesystemHandler) HandleWriteFile( 13 | ctx context.Context, 14 | request mcp.CallToolRequest, 15 | ) (*mcp.CallToolResult, error) { 16 | path, err := request.RequireString("path") 17 | if err != nil { 18 | return nil, err 19 | } 20 | content, err := request.RequireString("content") 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | // Handle empty or relative paths like "." or "./" by converting to absolute path 26 | if path == "." || path == "./" { 27 | // Get current working directory 28 | cwd, err := os.Getwd() 29 | if err != nil { 30 | return &mcp.CallToolResult{ 31 | Content: []mcp.Content{ 32 | mcp.TextContent{ 33 | Type: "text", 34 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 35 | }, 36 | }, 37 | IsError: true, 38 | }, nil 39 | } 40 | path = cwd 41 | } 42 | 43 | validPath, err := fs.validatePath(path) 44 | if err != nil { 45 | return &mcp.CallToolResult{ 46 | Content: []mcp.Content{ 47 | mcp.TextContent{ 48 | Type: "text", 49 | Text: fmt.Sprintf("Error: %v", err), 50 | }, 51 | }, 52 | IsError: true, 53 | }, nil 54 | } 55 | 56 | // Check if it's a directory 57 | if info, err := os.Stat(validPath); err == nil && info.IsDir() { 58 | return &mcp.CallToolResult{ 59 | Content: []mcp.Content{ 60 | mcp.TextContent{ 61 | Type: "text", 62 | Text: "Error: Cannot write to a directory", 63 | }, 64 | }, 65 | IsError: true, 66 | }, nil 67 | } 68 | 69 | // Create parent directories if they don't exist 70 | parentDir := filepath.Dir(validPath) 71 | if err := os.MkdirAll(parentDir, 0755); err != nil { 72 | return &mcp.CallToolResult{ 73 | Content: []mcp.Content{ 74 | mcp.TextContent{ 75 | Type: "text", 76 | Text: fmt.Sprintf("Error creating parent directories: %v", err), 77 | }, 78 | }, 79 | IsError: true, 80 | }, nil 81 | } 82 | 83 | if err := os.WriteFile(validPath, []byte(content), 0644); err != nil { 84 | return &mcp.CallToolResult{ 85 | Content: []mcp.Content{ 86 | mcp.TextContent{ 87 | Type: "text", 88 | Text: fmt.Sprintf("Error writing file: %v", err), 89 | }, 90 | }, 91 | IsError: true, 92 | }, nil 93 | } 94 | 95 | // Get file info for the response 96 | info, err := os.Stat(validPath) 97 | if err != nil { 98 | // File was written but we couldn't get info 99 | return &mcp.CallToolResult{ 100 | Content: []mcp.Content{ 101 | mcp.TextContent{ 102 | Type: "text", 103 | Text: fmt.Sprintf("Successfully wrote to %s", path), 104 | }, 105 | }, 106 | }, nil 107 | } 108 | 109 | resourceURI := pathToResourceURI(validPath) 110 | return &mcp.CallToolResult{ 111 | Content: []mcp.Content{ 112 | mcp.TextContent{ 113 | Type: "text", 114 | Text: fmt.Sprintf("Successfully wrote %d bytes to %s", info.Size(), path), 115 | }, 116 | mcp.EmbeddedResource{ 117 | Type: "resource", 118 | Resource: mcp.TextResourceContents{ 119 | URI: resourceURI, 120 | MIMEType: "text/plain", 121 | Text: fmt.Sprintf("File: %s (%d bytes)", validPath, info.Size()), 122 | }, 123 | }, 124 | }, 125 | }, nil 126 | } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/list_directory.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/mark3labs/mcp-go/mcp" 11 | ) 12 | 13 | func (fs *FilesystemHandler) HandleListDirectory( 14 | ctx context.Context, 15 | request mcp.CallToolRequest, 16 | ) (*mcp.CallToolResult, error) { 17 | path, err := request.RequireString("path") 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | // Handle empty or relative paths like "." or "./" by converting to absolute path 23 | if path == "." || path == "./" { 24 | // Get current working directory 25 | cwd, err := os.Getwd() 26 | if err != nil { 27 | return &mcp.CallToolResult{ 28 | Content: []mcp.Content{ 29 | mcp.TextContent{ 30 | Type: "text", 31 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 32 | }, 33 | }, 34 | IsError: true, 35 | }, nil 36 | } 37 | path = cwd 38 | } 39 | 40 | validPath, err := fs.validatePath(path) 41 | if err != nil { 42 | return &mcp.CallToolResult{ 43 | Content: []mcp.Content{ 44 | mcp.TextContent{ 45 | Type: "text", 46 | Text: fmt.Sprintf("Error: %v", err), 47 | }, 48 | }, 49 | IsError: true, 50 | }, nil 51 | } 52 | 53 | // Check if it's a directory 54 | info, err := os.Stat(validPath) 55 | if err != nil { 56 | return &mcp.CallToolResult{ 57 | Content: []mcp.Content{ 58 | mcp.TextContent{ 59 | Type: "text", 60 | Text: fmt.Sprintf("Error: %v", err), 61 | }, 62 | }, 63 | IsError: true, 64 | }, nil 65 | } 66 | 67 | if !info.IsDir() { 68 | return &mcp.CallToolResult{ 69 | Content: []mcp.Content{ 70 | mcp.TextContent{ 71 | Type: "text", 72 | Text: "Error: Path is not a directory", 73 | }, 74 | }, 75 | IsError: true, 76 | }, nil 77 | } 78 | 79 | entries, err := os.ReadDir(validPath) 80 | if err != nil { 81 | return &mcp.CallToolResult{ 82 | Content: []mcp.Content{ 83 | mcp.TextContent{ 84 | Type: "text", 85 | Text: fmt.Sprintf("Error reading directory: %v", err), 86 | }, 87 | }, 88 | IsError: true, 89 | }, nil 90 | } 91 | 92 | var result strings.Builder 93 | result.WriteString(fmt.Sprintf("Directory listing for: %s\n\n", validPath)) 94 | 95 | for _, entry := range entries { 96 | entryPath := filepath.Join(validPath, entry.Name()) 97 | resourceURI := pathToResourceURI(entryPath) 98 | 99 | if entry.IsDir() { 100 | result.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", entry.Name(), resourceURI)) 101 | } else { 102 | info, err := entry.Info() 103 | if err == nil { 104 | result.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", 105 | entry.Name(), resourceURI, info.Size())) 106 | } else { 107 | result.WriteString(fmt.Sprintf("[FILE] %s (%s)\n", entry.Name(), resourceURI)) 108 | } 109 | } 110 | } 111 | 112 | // Return both text content and embedded resource 113 | resourceURI := pathToResourceURI(validPath) 114 | return &mcp.CallToolResult{ 115 | Content: []mcp.Content{ 116 | mcp.TextContent{ 117 | Type: "text", 118 | Text: result.String(), 119 | }, 120 | mcp.EmbeddedResource{ 121 | Type: "resource", 122 | Resource: mcp.TextResourceContents{ 123 | URI: resourceURI, 124 | MIMEType: "text/plain", 125 | Text: fmt.Sprintf("Directory: %s", validPath), 126 | }, 127 | }, 128 | }, 129 | }, nil 130 | } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/delete_file.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | 8 | "github.com/mark3labs/mcp-go/mcp" 9 | ) 10 | 11 | func (fs *FilesystemHandler) HandleDeleteFile( 12 | ctx context.Context, 13 | request mcp.CallToolRequest, 14 | ) (*mcp.CallToolResult, error) { 15 | path, err := request.RequireString("path") 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | // Handle empty or relative paths like "." or "./" by converting to absolute path 21 | if path == "." || path == "./" { 22 | // Get current working directory 23 | cwd, err := os.Getwd() 24 | if err != nil { 25 | return &mcp.CallToolResult{ 26 | Content: []mcp.Content{ 27 | mcp.TextContent{ 28 | Type: "text", 29 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 30 | }, 31 | }, 32 | IsError: true, 33 | }, nil 34 | } 35 | path = cwd 36 | } 37 | 38 | validPath, err := fs.validatePath(path) 39 | if err != nil { 40 | return &mcp.CallToolResult{ 41 | Content: []mcp.Content{ 42 | mcp.TextContent{ 43 | Type: "text", 44 | Text: fmt.Sprintf("Error: %v", err), 45 | }, 46 | }, 47 | IsError: true, 48 | }, nil 49 | } 50 | 51 | // Check if path exists 52 | info, err := os.Stat(validPath) 53 | if os.IsNotExist(err) { 54 | return &mcp.CallToolResult{ 55 | Content: []mcp.Content{ 56 | mcp.TextContent{ 57 | Type: "text", 58 | Text: fmt.Sprintf("Error: Path does not exist: %s", path), 59 | }, 60 | }, 61 | IsError: true, 62 | }, nil 63 | } else if err != nil { 64 | return &mcp.CallToolResult{ 65 | Content: []mcp.Content{ 66 | mcp.TextContent{ 67 | Type: "text", 68 | Text: fmt.Sprintf("Error accessing path: %v", err), 69 | }, 70 | }, 71 | IsError: true, 72 | }, nil 73 | } 74 | 75 | // Extract recursive parameter (optional, default: false) 76 | recursive := false 77 | if recursiveParam, err := request.RequireBool("recursive"); err == nil { 78 | recursive = recursiveParam 79 | } 80 | 81 | // Check if it's a directory and handle accordingly 82 | if info.IsDir() { 83 | if !recursive { 84 | return &mcp.CallToolResult{ 85 | Content: []mcp.Content{ 86 | mcp.TextContent{ 87 | Type: "text", 88 | Text: fmt.Sprintf("Error: %s is a directory. Use recursive=true to delete directories.", path), 89 | }, 90 | }, 91 | IsError: true, 92 | }, nil 93 | } 94 | 95 | // It's a directory and recursive is true, so remove it 96 | if err := os.RemoveAll(validPath); err != nil { 97 | return &mcp.CallToolResult{ 98 | Content: []mcp.Content{ 99 | mcp.TextContent{ 100 | Type: "text", 101 | Text: fmt.Sprintf("Error deleting directory: %v", err), 102 | }, 103 | }, 104 | IsError: true, 105 | }, nil 106 | } 107 | 108 | return &mcp.CallToolResult{ 109 | Content: []mcp.Content{ 110 | mcp.TextContent{ 111 | Type: "text", 112 | Text: fmt.Sprintf("Successfully deleted directory %s", path), 113 | }, 114 | }, 115 | }, nil 116 | } 117 | 118 | // It's a file, delete it 119 | if err := os.Remove(validPath); err != nil { 120 | return &mcp.CallToolResult{ 121 | Content: []mcp.Content{ 122 | mcp.TextContent{ 123 | Type: "text", 124 | Text: fmt.Sprintf("Error deleting file: %v", err), 125 | }, 126 | }, 127 | IsError: true, 128 | }, nil 129 | } 130 | 131 | return &mcp.CallToolResult{ 132 | Content: []mcp.Content{ 133 | mcp.TextContent{ 134 | Type: "text", 135 | Text: fmt.Sprintf("Successfully deleted file %s", path), 136 | }, 137 | }, 138 | }, nil 139 | } 140 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/get_file_info.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/djherbis/times" 10 | "github.com/mark3labs/mcp-go/mcp" 11 | ) 12 | 13 | func (fs *FilesystemHandler) HandleGetFileInfo( 14 | ctx context.Context, 15 | request mcp.CallToolRequest, 16 | ) (*mcp.CallToolResult, error) { 17 | path, err := request.RequireString("path") 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | // Handle empty or relative paths like "." or "./" by converting to absolute path 23 | if path == "." || path == "./" { 24 | // Get current working directory 25 | cwd, err := os.Getwd() 26 | if err != nil { 27 | return &mcp.CallToolResult{ 28 | Content: []mcp.Content{ 29 | mcp.TextContent{ 30 | Type: "text", 31 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 32 | }, 33 | }, 34 | IsError: true, 35 | }, nil 36 | } 37 | path = cwd 38 | } 39 | 40 | validPath, err := fs.validatePath(path) 41 | if err != nil { 42 | return &mcp.CallToolResult{ 43 | Content: []mcp.Content{ 44 | mcp.TextContent{ 45 | Type: "text", 46 | Text: fmt.Sprintf("Error: %v", err), 47 | }, 48 | }, 49 | IsError: true, 50 | }, nil 51 | } 52 | 53 | info, err := fs.getFileStats(validPath) 54 | if err != nil { 55 | return &mcp.CallToolResult{ 56 | Content: []mcp.Content{ 57 | mcp.TextContent{ 58 | Type: "text", 59 | Text: fmt.Sprintf("Error getting file info: %v", err), 60 | }, 61 | }, 62 | IsError: true, 63 | }, nil 64 | } 65 | 66 | // Get MIME type for files 67 | mimeType := "directory" 68 | if info.IsFile { 69 | mimeType = detectMimeType(validPath) 70 | } 71 | 72 | resourceURI := pathToResourceURI(validPath) 73 | 74 | // Determine file type text 75 | var fileTypeText string 76 | if info.IsDirectory { 77 | fileTypeText = "Directory" 78 | } else { 79 | fileTypeText = "File" 80 | } 81 | 82 | return &mcp.CallToolResult{ 83 | Content: []mcp.Content{ 84 | mcp.TextContent{ 85 | Type: "text", 86 | Text: fmt.Sprintf( 87 | "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", 88 | validPath, 89 | info.Size, 90 | info.Created.Format(time.RFC3339), 91 | info.Modified.Format(time.RFC3339), 92 | info.Accessed.Format(time.RFC3339), 93 | info.IsDirectory, 94 | info.IsFile, 95 | info.Permissions, 96 | mimeType, 97 | resourceURI, 98 | ), 99 | }, 100 | mcp.EmbeddedResource{ 101 | Type: "resource", 102 | Resource: mcp.TextResourceContents{ 103 | URI: resourceURI, 104 | MIMEType: "text/plain", 105 | Text: fmt.Sprintf("%s: %s (%s, %d bytes)", 106 | fileTypeText, 107 | validPath, 108 | mimeType, 109 | info.Size), 110 | }, 111 | }, 112 | }, 113 | }, nil 114 | } 115 | 116 | func (fs *FilesystemHandler) getFileStats(path string) (FileInfo, error) { 117 | info, err := os.Stat(path) 118 | if err != nil { 119 | return FileInfo{}, err 120 | } 121 | 122 | timespec, err := times.Stat(path) 123 | if err != nil { 124 | return FileInfo{}, fmt.Errorf("failed to get file times: %w", err) 125 | } 126 | 127 | createdTime := time.Time{} 128 | if timespec.HasBirthTime() { 129 | createdTime = timespec.BirthTime() 130 | } 131 | 132 | return FileInfo{ 133 | Size: info.Size(), 134 | Created: createdTime, 135 | Modified: timespec.ModTime(), 136 | Accessed: timespec.AccessTime(), 137 | IsDirectory: info.IsDir(), 138 | IsFile: !info.IsDir(), 139 | Permissions: fmt.Sprintf("%o", info.Mode().Perm()), 140 | }, nil 141 | } 142 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/get_file_info_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestHandleGetFileInfo(t *testing.T) { 15 | // Setup a temporary directory for the test 16 | tmpDir := t.TempDir() 17 | 18 | // Create a handler with the temp dir as an allowed path 19 | allowedDirs := resolveAllowedDirs(t, tmpDir) 20 | fsHandler, err := NewFilesystemHandler(allowedDirs) 21 | require.NoError(t, err) 22 | 23 | ctx := context.Background() 24 | 25 | t.Run("get file info for a file", func(t *testing.T) { 26 | filePath := filepath.Join(tmpDir, "test_file.txt") 27 | fileContent := "Hello, world!" 28 | err := os.WriteFile(filePath, []byte(fileContent), 0644) 29 | require.NoError(t, err) 30 | 31 | req := mcp.CallToolRequest{ 32 | Params: mcp.CallToolParams{ 33 | Arguments: map[string]interface{}{ 34 | "path": filePath, 35 | }, 36 | }, 37 | } 38 | 39 | res, err := fsHandler.HandleGetFileInfo(ctx, req) 40 | require.NoError(t, err) 41 | require.False(t, res.IsError) 42 | 43 | // Verify the response contains file information 44 | require.Len(t, res.Content, 2) 45 | textContent := res.Content[0].(mcp.TextContent) 46 | assert.Contains(t, textContent.Text, "File information for:") 47 | assert.Contains(t, textContent.Text, filePath) 48 | assert.Contains(t, textContent.Text, "IsFile: true") 49 | assert.Contains(t, textContent.Text, "IsDirectory: false") 50 | assert.Contains(t, textContent.Text, "Size: 13 bytes") // Length of "Hello, world!" 51 | }) 52 | 53 | t.Run("get file info for a directory", func(t *testing.T) { 54 | dirPath := filepath.Join(tmpDir, "test_directory") 55 | err := os.Mkdir(dirPath, 0755) 56 | require.NoError(t, err) 57 | 58 | req := mcp.CallToolRequest{ 59 | Params: mcp.CallToolParams{ 60 | Arguments: map[string]interface{}{ 61 | "path": dirPath, 62 | }, 63 | }, 64 | } 65 | 66 | res, err := fsHandler.HandleGetFileInfo(ctx, req) 67 | require.NoError(t, err) 68 | require.False(t, res.IsError) 69 | 70 | // Verify the response contains directory information 71 | require.Len(t, res.Content, 2) 72 | textContent := res.Content[0].(mcp.TextContent) 73 | assert.Contains(t, textContent.Text, "File information for:") 74 | assert.Contains(t, textContent.Text, dirPath) 75 | assert.Contains(t, textContent.Text, "IsFile: false") 76 | assert.Contains(t, textContent.Text, "IsDirectory: true") 77 | assert.Contains(t, textContent.Text, "MIME Type: directory") 78 | }) 79 | 80 | t.Run("file does not exist", func(t *testing.T) { 81 | nonExistentPath := filepath.Join(tmpDir, "non_existent_file.txt") 82 | 83 | req := mcp.CallToolRequest{ 84 | Params: mcp.CallToolParams{ 85 | Arguments: map[string]interface{}{ 86 | "path": nonExistentPath, 87 | }, 88 | }, 89 | } 90 | 91 | res, err := fsHandler.HandleGetFileInfo(ctx, req) 92 | require.NoError(t, err) 93 | require.True(t, res.IsError) 94 | }) 95 | 96 | t.Run("path is in a non-allowed directory", func(t *testing.T) { 97 | otherDir := t.TempDir() 98 | 99 | req := mcp.CallToolRequest{ 100 | Params: mcp.CallToolParams{ 101 | Arguments: map[string]interface{}{ 102 | "path": filepath.Join(otherDir, "some_file.txt"), 103 | }, 104 | }, 105 | } 106 | 107 | res, err := fsHandler.HandleGetFileInfo(ctx, req) 108 | require.NoError(t, err) 109 | require.True(t, res.IsError) 110 | }) 111 | } 112 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/resources.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/mark3labs/mcp-go/mcp" 12 | ) 13 | 14 | // HandleReadResource handles the MCP resource reading functionality 15 | func (fs *FilesystemHandler) HandleReadResource( 16 | ctx context.Context, 17 | request mcp.ReadResourceRequest, 18 | ) ([]mcp.ResourceContents, error) { 19 | uri := request.Params.URI 20 | 21 | // Check if it's a file:// URI 22 | if !strings.HasPrefix(uri, "file://") { 23 | return nil, fmt.Errorf("unsupported URI scheme: %s", uri) 24 | } 25 | 26 | // Extract the path from the URI 27 | path := strings.TrimPrefix(uri, "file://") 28 | 29 | // Validate the path 30 | validPath, err := fs.validatePath(path) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | // Get file info 36 | fileInfo, err := os.Stat(validPath) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | // If it's a directory, return a listing 42 | if fileInfo.IsDir() { 43 | entries, err := os.ReadDir(validPath) 44 | if err != nil { 45 | return nil, err 46 | } 47 | 48 | var result strings.Builder 49 | result.WriteString(fmt.Sprintf("Directory listing for: %s\n\n", validPath)) 50 | 51 | for _, entry := range entries { 52 | entryPath := filepath.Join(validPath, entry.Name()) 53 | entryURI := pathToResourceURI(entryPath) 54 | 55 | if entry.IsDir() { 56 | result.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", entry.Name(), entryURI)) 57 | } else { 58 | info, err := entry.Info() 59 | if err == nil { 60 | result.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", 61 | entry.Name(), entryURI, info.Size())) 62 | } else { 63 | result.WriteString(fmt.Sprintf("[FILE] %s (%s)\n", entry.Name(), entryURI)) 64 | } 65 | } 66 | } 67 | 68 | return []mcp.ResourceContents{ 69 | mcp.TextResourceContents{ 70 | URI: uri, 71 | MIMEType: "text/plain", 72 | Text: result.String(), 73 | }, 74 | }, nil 75 | } 76 | 77 | // It's a file, determine how to handle it 78 | mimeType := detectMimeType(validPath) 79 | 80 | // Check file size 81 | if fileInfo.Size() > MAX_INLINE_SIZE { 82 | // File is too large to inline, return a reference instead 83 | return []mcp.ResourceContents{ 84 | mcp.TextResourceContents{ 85 | URI: uri, 86 | MIMEType: "text/plain", 87 | Text: fmt.Sprintf("File is too large to display inline (%d bytes). Use the read_file tool to access specific portions.", fileInfo.Size()), 88 | }, 89 | }, nil 90 | } 91 | 92 | // Read the file content 93 | content, err := os.ReadFile(validPath) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | // Handle based on content type 99 | if isTextFile(mimeType) { 100 | // It's a text file, return as text 101 | return []mcp.ResourceContents{ 102 | mcp.TextResourceContents{ 103 | URI: uri, 104 | MIMEType: mimeType, 105 | Text: string(content), 106 | }, 107 | }, nil 108 | } else { 109 | // It's a binary file 110 | if fileInfo.Size() <= MAX_BASE64_SIZE { 111 | // Small enough for base64 encoding 112 | return []mcp.ResourceContents{ 113 | mcp.BlobResourceContents{ 114 | URI: uri, 115 | MIMEType: mimeType, 116 | Blob: base64.StdEncoding.EncodeToString(content), 117 | }, 118 | }, nil 119 | } else { 120 | // Too large for base64, return a reference 121 | return []mcp.ResourceContents{ 122 | mcp.TextResourceContents{ 123 | URI: uri, 124 | MIMEType: "text/plain", 125 | Text: fmt.Sprintf("Binary file (%s, %d bytes). Use the read_file tool to access specific portions.", mimeType, fileInfo.Size()), 126 | }, 127 | }, nil 128 | } 129 | } 130 | } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/copy_file_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestHandleCopyFile(t *testing.T) { 15 | // Setup a temporary directory for the test 16 | tmpDir := t.TempDir() 17 | 18 | // Create a handler with the temp dir as an allowed path 19 | allowedDirs := resolveAllowedDirs(t, tmpDir) 20 | fsHandler, err := NewFilesystemHandler(allowedDirs) 21 | require.NoError(t, err) 22 | 23 | ctx := context.Background() 24 | 25 | // Create a source file 26 | sourceFilePath := filepath.Join(tmpDir, "source.txt") 27 | err = os.WriteFile(sourceFilePath, []byte("hello world"), 0644) 28 | require.NoError(t, err) 29 | 30 | // Create a source directory 31 | sourceDirPath := filepath.Join(tmpDir, "source_dir") 32 | err = os.Mkdir(sourceDirPath, 0755) 33 | require.NoError(t, err) 34 | 35 | // Create a file inside the source directory 36 | nestedFilePath := filepath.Join(sourceDirPath, "nested.txt") 37 | err = os.WriteFile(nestedFilePath, []byte("nested hello"), 0644) 38 | require.NoError(t, err) 39 | 40 | t.Run("copy a single file", func(t *testing.T) { 41 | destinationPath := filepath.Join(tmpDir, "destination.txt") 42 | req := mcp.CallToolRequest{ 43 | Params: mcp.CallToolParams{ 44 | Arguments: map[string]interface{}{ 45 | "source": sourceFilePath, 46 | "destination": destinationPath, 47 | }, 48 | }, 49 | } 50 | 51 | res, err := fsHandler.HandleCopyFile(ctx, req) 52 | require.NoError(t, err) 53 | require.False(t, res.IsError) 54 | 55 | // Verify the file was copied 56 | _, err = os.Stat(destinationPath) 57 | require.NoError(t, err) 58 | 59 | content, err := os.ReadFile(destinationPath) 60 | require.NoError(t, err) 61 | assert.Equal(t, "hello world", string(content)) 62 | }) 63 | 64 | t.Run("copy a directory", func(t *testing.T) { 65 | destinationPath := filepath.Join(tmpDir, "destination_dir") 66 | req := mcp.CallToolRequest{ 67 | Params: mcp.CallToolParams{ 68 | Arguments: map[string]interface{}{ 69 | "source": sourceDirPath, 70 | "destination": destinationPath, 71 | }, 72 | }, 73 | } 74 | 75 | res, err := fsHandler.HandleCopyFile(ctx, req) 76 | require.NoError(t, err) 77 | require.False(t, res.IsError) 78 | 79 | // Verify the directory was copied 80 | _, err = os.Stat(destinationPath) 81 | require.NoError(t, err) 82 | 83 | // Verify the nested file was copied 84 | nestedDestPath := filepath.Join(destinationPath, "nested.txt") 85 | _, err = os.Stat(nestedDestPath) 86 | require.NoError(t, err) 87 | 88 | content, err := os.ReadFile(nestedDestPath) 89 | require.NoError(t, err) 90 | assert.Equal(t, "nested hello", string(content)) 91 | }) 92 | 93 | t.Run("source does not exist", func(t *testing.T) { 94 | req := mcp.CallToolRequest{ 95 | Params: mcp.CallToolParams{ 96 | Arguments: map[string]interface{}{ 97 | "source": filepath.Join(tmpDir, "non-existent-file.txt"), 98 | "destination": filepath.Join(tmpDir, "destination.txt"), 99 | }, 100 | }, 101 | } 102 | 103 | res, err := fsHandler.HandleCopyFile(ctx, req) 104 | require.NoError(t, err) 105 | require.True(t, res.IsError) 106 | }) 107 | 108 | t.Run("destination is in a non-allowed directory", func(t *testing.T) { 109 | // Setup a temporary directory for the test 110 | otherDir := t.TempDir() 111 | 112 | req := mcp.CallToolRequest{ 113 | Params: mcp.CallToolParams{ 114 | Arguments: map[string]interface{}{ 115 | "source": sourceFilePath, 116 | "destination": filepath.Join(otherDir, "destination.txt"), 117 | }, 118 | }, 119 | } 120 | 121 | res, err := fsHandler.HandleCopyFile(ctx, req) 122 | require.NoError(t, err) 123 | require.True(t, res.IsError) 124 | }) 125 | } 126 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/search_files.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/gobwas/glob" 11 | "github.com/mark3labs/mcp-go/mcp" 12 | ) 13 | 14 | func (fs *FilesystemHandler) HandleSearchFiles( 15 | ctx context.Context, 16 | request mcp.CallToolRequest, 17 | ) (*mcp.CallToolResult, error) { 18 | path, err := request.RequireString("path") 19 | if err != nil { 20 | return nil, err 21 | } 22 | pattern, err := request.RequireString("pattern") 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | // Handle empty or relative paths like "." or "./" by converting to absolute path 28 | if path == "." || path == "./" { 29 | // Get current working directory 30 | cwd, err := os.Getwd() 31 | if err != nil { 32 | return &mcp.CallToolResult{ 33 | Content: []mcp.Content{ 34 | mcp.TextContent{ 35 | Type: "text", 36 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 37 | }, 38 | }, 39 | IsError: true, 40 | }, nil 41 | } 42 | path = cwd 43 | } 44 | 45 | validPath, err := fs.validatePath(path) 46 | if err != nil { 47 | return &mcp.CallToolResult{ 48 | Content: []mcp.Content{ 49 | mcp.TextContent{ 50 | Type: "text", 51 | Text: fmt.Sprintf("Error: %v", err), 52 | }, 53 | }, 54 | IsError: true, 55 | }, nil 56 | } 57 | 58 | // Check if it's a directory 59 | info, err := os.Stat(validPath) 60 | if err != nil { 61 | return &mcp.CallToolResult{ 62 | Content: []mcp.Content{ 63 | mcp.TextContent{ 64 | Type: "text", 65 | Text: fmt.Sprintf("Error: %v", err), 66 | }, 67 | }, 68 | IsError: true, 69 | }, nil 70 | } 71 | 72 | if !info.IsDir() { 73 | return &mcp.CallToolResult{ 74 | Content: []mcp.Content{ 75 | mcp.TextContent{ 76 | Type: "text", 77 | Text: "Error: Search path must be a directory", 78 | }, 79 | }, 80 | IsError: true, 81 | }, nil 82 | } 83 | 84 | results, err := searchFiles(validPath, pattern, fs) 85 | if err != nil { 86 | return &mcp.CallToolResult{ 87 | Content: []mcp.Content{ 88 | mcp.TextContent{ 89 | Type: "text", 90 | Text: fmt.Sprintf("Error searching files: %v", 91 | err), 92 | }, 93 | }, 94 | IsError: true, 95 | }, nil 96 | } 97 | 98 | if len(results) == 0 { 99 | return &mcp.CallToolResult{ 100 | Content: []mcp.Content{ 101 | mcp.TextContent{ 102 | Type: "text", 103 | Text: fmt.Sprintf("No files found matching pattern '%s' in %s", pattern, path), 104 | }, 105 | }, 106 | }, nil 107 | } 108 | 109 | // Format results with resource URIs 110 | var formattedResults strings.Builder 111 | formattedResults.WriteString(fmt.Sprintf("Found %d results:\n\n", len(results))) 112 | 113 | for _, result := range results { 114 | resourceURI := pathToResourceURI(result) 115 | info, err := os.Stat(result) 116 | if err == nil { 117 | if info.IsDir() { 118 | formattedResults.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", result, resourceURI)) 119 | } else { 120 | formattedResults.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", 121 | result, resourceURI, info.Size())) 122 | } 123 | } else { 124 | formattedResults.WriteString(fmt.Sprintf("%s (%s)\n", result, resourceURI)) 125 | } 126 | } 127 | 128 | return &mcp.CallToolResult{ 129 | Content: []mcp.Content{ 130 | mcp.TextContent{ 131 | Type: "text", 132 | Text: formattedResults.String(), 133 | }, 134 | }, 135 | }, nil 136 | } 137 | 138 | func searchFiles(rootPath, pattern string, fs *FilesystemHandler) ([]string, error) { 139 | var results []string 140 | globPattern := glob.MustCompile(pattern) 141 | 142 | err := filepath.Walk( 143 | rootPath, 144 | func(path string, info os.FileInfo, err error) error { 145 | if err != nil { 146 | return nil // Skip errors and continue 147 | } 148 | 149 | // Try to validate path 150 | if _, err := fs.validatePath(path); err != nil { 151 | return nil // Skip invalid paths 152 | } 153 | 154 | if globPattern.Match(info.Name()) { 155 | results = append(results, path) 156 | } 157 | return nil 158 | }, 159 | ) 160 | if err != nil { 161 | return nil, err 162 | } 163 | return results, nil 164 | } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/move_file.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | func (fs *FilesystemHandler) HandleMoveFile( 13 | ctx context.Context, 14 | request mcp.CallToolRequest, 15 | ) (*mcp.CallToolResult, error) { 16 | source, err := request.RequireString("source") 17 | if err != nil { 18 | return nil, err 19 | } 20 | destination, err := request.RequireString("destination") 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | // Handle empty or relative paths for source 26 | if source == "." || source == "./" { 27 | // Get current working directory 28 | cwd, err := os.Getwd() 29 | if err != nil { 30 | return &mcp.CallToolResult{ 31 | Content: []mcp.Content{ 32 | mcp.TextContent{ 33 | Type: "text", 34 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 35 | }, 36 | }, 37 | IsError: true, 38 | }, nil 39 | } 40 | source = cwd 41 | } 42 | 43 | // Handle empty or relative paths for destination 44 | if destination == "." || destination == "./" { 45 | // Get current working directory 46 | cwd, err := os.Getwd() 47 | if err != nil { 48 | return &mcp.CallToolResult{ 49 | Content: []mcp.Content{ 50 | mcp.TextContent{ 51 | Type: "text", 52 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 53 | }, 54 | }, 55 | IsError: true, 56 | }, nil 57 | } 58 | destination = cwd 59 | } 60 | 61 | validSource, err := fs.validatePath(source) 62 | if err != nil { 63 | return &mcp.CallToolResult{ 64 | Content: []mcp.Content{ 65 | mcp.TextContent{ 66 | Type: "text", 67 | Text: fmt.Sprintf("Error with source path: %v", err), 68 | }, 69 | }, 70 | IsError: true, 71 | }, nil 72 | } 73 | 74 | // Check if source exists 75 | if _, err := os.Stat(validSource); os.IsNotExist(err) { 76 | return &mcp.CallToolResult{ 77 | Content: []mcp.Content{ 78 | mcp.TextContent{ 79 | Type: "text", 80 | Text: fmt.Sprintf("Error: Source does not exist: %s", source), 81 | }, 82 | }, 83 | IsError: true, 84 | }, nil 85 | } 86 | 87 | // For destination path, validate the parent directory first and create it if needed 88 | destDir := filepath.Dir(destination) 89 | validDestDir, err := fs.validatePath(destDir) 90 | if err != nil { 91 | return &mcp.CallToolResult{ 92 | Content: []mcp.Content{ 93 | mcp.TextContent{ 94 | Type: "text", 95 | Text: fmt.Sprintf("Error with destination directory path: %v", err), 96 | }, 97 | }, 98 | IsError: true, 99 | }, nil 100 | } 101 | 102 | // Create parent directory for destination if it doesn't exist 103 | if err := os.MkdirAll(validDestDir, 0755); err != nil { 104 | return &mcp.CallToolResult{ 105 | Content: []mcp.Content{ 106 | mcp.TextContent{ 107 | Type: "text", 108 | Text: fmt.Sprintf("Error creating destination directory: %v", err), 109 | }, 110 | }, 111 | IsError: true, 112 | }, nil 113 | } 114 | 115 | // Now validate the full destination path 116 | validDest, err := fs.validatePath(destination) 117 | if err != nil { 118 | return &mcp.CallToolResult{ 119 | Content: []mcp.Content{ 120 | mcp.TextContent{ 121 | Type: "text", 122 | Text: fmt.Sprintf("Error with destination path: %v", err), 123 | }, 124 | }, 125 | IsError: true, 126 | }, nil 127 | } 128 | 129 | if err := os.Rename(validSource, validDest); err != nil { 130 | return &mcp.CallToolResult{ 131 | Content: []mcp.Content{ 132 | mcp.TextContent{ 133 | Type: "text", 134 | Text: fmt.Sprintf("Error moving file: %v", err), 135 | }, 136 | }, 137 | IsError: true, 138 | }, nil 139 | } 140 | 141 | resourceURI := pathToResourceURI(validDest) 142 | return &mcp.CallToolResult{ 143 | Content: []mcp.Content{ 144 | mcp.TextContent{ 145 | Type: "text", 146 | Text: fmt.Sprintf( 147 | "Successfully moved %s to %s", 148 | source, 149 | destination, 150 | ), 151 | }, 152 | mcp.EmbeddedResource{ 153 | Type: "resource", 154 | Resource: mcp.TextResourceContents{ 155 | URI: resourceURI, 156 | MIMEType: "text/plain", 157 | Text: fmt.Sprintf("Moved file: %s", validDest), 158 | }, 159 | }, 160 | }, 161 | }, nil 162 | } 163 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/list_directory_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestHandleListDirectory(t *testing.T) { 15 | // Setup a temporary directory for the test 16 | tmpDir := t.TempDir() 17 | 18 | // Create a handler with the temp dir as an allowed path 19 | allowedDirs := resolveAllowedDirs(t, tmpDir) 20 | fsHandler, err := NewFilesystemHandler(allowedDirs) 21 | require.NoError(t, err) 22 | 23 | ctx := context.Background() 24 | 25 | // Create test directory structure 26 | subDir := filepath.Join(tmpDir, "subdirectory") 27 | err = os.Mkdir(subDir, 0755) 28 | require.NoError(t, err) 29 | 30 | testFile := filepath.Join(tmpDir, "test_file.txt") 31 | err = os.WriteFile(testFile, []byte("hello world"), 0644) 32 | require.NoError(t, err) 33 | 34 | t.Run("list directory with files and subdirectories", func(t *testing.T) { 35 | req := mcp.CallToolRequest{ 36 | Params: mcp.CallToolParams{ 37 | Arguments: map[string]interface{}{ 38 | "path": tmpDir, 39 | }, 40 | }, 41 | } 42 | 43 | res, err := fsHandler.HandleListDirectory(ctx, req) 44 | require.NoError(t, err) 45 | require.False(t, res.IsError) 46 | 47 | // Verify the response contains directory listing 48 | require.Len(t, res.Content, 2) 49 | textContent := res.Content[0].(mcp.TextContent) 50 | assert.Contains(t, textContent.Text, "Directory listing for:") 51 | assert.Contains(t, textContent.Text, tmpDir) 52 | assert.Contains(t, textContent.Text, "[DIR] subdirectory") 53 | assert.Contains(t, textContent.Text, "[FILE] test_file.txt") 54 | assert.Contains(t, textContent.Text, "11 bytes") // Length of "hello world" 55 | assert.Contains(t, textContent.Text, "file://") 56 | 57 | // Verify embedded resource 58 | embeddedResource := res.Content[1].(mcp.EmbeddedResource) 59 | assert.Equal(t, "resource", embeddedResource.Type) 60 | }) 61 | 62 | t.Run("list empty directory", func(t *testing.T) { 63 | emptyDir := filepath.Join(tmpDir, "empty_directory") 64 | err := os.Mkdir(emptyDir, 0755) 65 | require.NoError(t, err) 66 | 67 | req := mcp.CallToolRequest{ 68 | Params: mcp.CallToolParams{ 69 | Arguments: map[string]interface{}{ 70 | "path": emptyDir, 71 | }, 72 | }, 73 | } 74 | 75 | res, err := fsHandler.HandleListDirectory(ctx, req) 76 | require.NoError(t, err) 77 | require.False(t, res.IsError) 78 | 79 | // Verify the response contains directory listing for empty directory 80 | require.Len(t, res.Content, 2) 81 | textContent := res.Content[0].(mcp.TextContent) 82 | assert.Contains(t, textContent.Text, "Directory listing for:") 83 | assert.Contains(t, textContent.Text, emptyDir) 84 | }) 85 | 86 | t.Run("try to list a file instead of directory", func(t *testing.T) { 87 | req := mcp.CallToolRequest{ 88 | Params: mcp.CallToolParams{ 89 | Arguments: map[string]interface{}{ 90 | "path": testFile, 91 | }, 92 | }, 93 | } 94 | 95 | res, err := fsHandler.HandleListDirectory(ctx, req) 96 | require.NoError(t, err) 97 | require.True(t, res.IsError) 98 | 99 | // Verify error message 100 | require.Len(t, res.Content, 1) 101 | textContent := res.Content[0].(mcp.TextContent) 102 | assert.Contains(t, textContent.Text, "Path is not a directory") 103 | }) 104 | 105 | t.Run("try to list non-existent directory", func(t *testing.T) { 106 | nonExistentPath := filepath.Join(tmpDir, "non_existent_directory") 107 | 108 | req := mcp.CallToolRequest{ 109 | Params: mcp.CallToolParams{ 110 | Arguments: map[string]interface{}{ 111 | "path": nonExistentPath, 112 | }, 113 | }, 114 | } 115 | 116 | res, err := fsHandler.HandleListDirectory(ctx, req) 117 | require.NoError(t, err) 118 | require.True(t, res.IsError) 119 | }) 120 | 121 | t.Run("path is in a non-allowed directory", func(t *testing.T) { 122 | otherDir := t.TempDir() 123 | 124 | req := mcp.CallToolRequest{ 125 | Params: mcp.CallToolParams{ 126 | Arguments: map[string]interface{}{ 127 | "path": otherDir, 128 | }, 129 | }, 130 | } 131 | 132 | res, err := fsHandler.HandleListDirectory(ctx, req) 133 | require.NoError(t, err) 134 | require.True(t, res.IsError) 135 | }) 136 | } 137 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/helper.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "fmt" 5 | "mime" 6 | "os" 7 | "path/filepath" 8 | "slices" 9 | "strings" 10 | 11 | "github.com/gabriel-vasile/mimetype" 12 | ) 13 | 14 | // isPathInAllowedDirs checks if a path is within any of the allowed directories 15 | func (fs *FilesystemHandler) isPathInAllowedDirs(path string) bool { 16 | // Ensure path is absolute and clean 17 | absPath, err := filepath.Abs(path) 18 | if err != nil { 19 | return false 20 | } 21 | 22 | // Add trailing separator to ensure we're checking a directory or a file within a directory 23 | // and not a prefix match (e.g., /tmp/foo should not match /tmp/foobar) 24 | if !strings.HasSuffix(absPath, string(filepath.Separator)) { 25 | // If it's a file, we need to check its directory 26 | if info, err := os.Stat(absPath); err == nil && !info.IsDir() { 27 | absPath = filepath.Dir(absPath) + string(filepath.Separator) 28 | } else { 29 | absPath = absPath + string(filepath.Separator) 30 | } 31 | } 32 | 33 | // Check if the path is within any of the allowed directories 34 | for _, dir := range fs.allowedDirs { 35 | if strings.HasPrefix(absPath, dir) { 36 | return true 37 | } 38 | } 39 | return false 40 | } 41 | 42 | func (fs *FilesystemHandler) validatePath(requestedPath string) (string, error) { 43 | // Always convert to absolute path first 44 | abs, err := filepath.Abs(requestedPath) 45 | if err != nil { 46 | return "", fmt.Errorf("invalid path: %w", err) 47 | } 48 | 49 | // Check if path is within allowed directories 50 | if !fs.isPathInAllowedDirs(abs) { 51 | return "", fmt.Errorf( 52 | "access denied - path outside allowed directories: %s", 53 | abs, 54 | ) 55 | } 56 | 57 | // Handle symlinks 58 | realPath, err := filepath.EvalSymlinks(abs) 59 | if err != nil { 60 | if !os.IsNotExist(err) { 61 | return "", err 62 | } 63 | // For new files, check parent directory 64 | parent := filepath.Dir(abs) 65 | realParent, err := filepath.EvalSymlinks(parent) 66 | if err != nil { 67 | return "", fmt.Errorf("parent directory does not exist: %s", parent) 68 | } 69 | 70 | if !fs.isPathInAllowedDirs(realParent) { 71 | return "", fmt.Errorf( 72 | "access denied - parent directory outside allowed directories", 73 | ) 74 | } 75 | return abs, nil 76 | } 77 | 78 | // Check if the real path (after resolving symlinks) is still within allowed directories 79 | if !fs.isPathInAllowedDirs(realPath) { 80 | return "", fmt.Errorf( 81 | "access denied - symlink target outside allowed directories", 82 | ) 83 | } 84 | 85 | return realPath, nil 86 | } 87 | 88 | // detectMimeType tries to determine the MIME type of a file 89 | func detectMimeType(path string) string { 90 | // Use mimetype library for more accurate detection 91 | mtype, err := mimetype.DetectFile(path) 92 | if err != nil { 93 | // Fallback to extension-based detection if file can't be read 94 | ext := filepath.Ext(path) 95 | if ext != "" { 96 | mimeType := mime.TypeByExtension(ext) 97 | if mimeType != "" { 98 | return mimeType 99 | } 100 | } 101 | return "application/octet-stream" // Default 102 | } 103 | 104 | return mtype.String() 105 | } 106 | 107 | // isTextFile determines if a file is likely a text file based on MIME type 108 | func isTextFile(mimeType string) bool { 109 | // Check for common text MIME types 110 | if strings.HasPrefix(mimeType, "text/") { 111 | return true 112 | } 113 | 114 | // Common application types that are text-based 115 | textApplicationTypes := []string{ 116 | "application/json", 117 | "application/xml", 118 | "application/javascript", 119 | "application/x-javascript", 120 | "application/typescript", 121 | "application/x-typescript", 122 | "application/x-yaml", 123 | "application/yaml", 124 | "application/toml", 125 | "application/x-sh", 126 | "application/x-shellscript", 127 | } 128 | 129 | if slices.Contains(textApplicationTypes, mimeType) { 130 | return true 131 | } 132 | 133 | // Check for +format types 134 | if strings.Contains(mimeType, "+xml") || 135 | strings.Contains(mimeType, "+json") || 136 | strings.Contains(mimeType, "+yaml") { 137 | return true 138 | } 139 | 140 | // Common code file types that might be misidentified 141 | if strings.HasPrefix(mimeType, "text/x-") { 142 | return true 143 | } 144 | 145 | if strings.HasPrefix(mimeType, "application/x-") && 146 | (strings.Contains(mimeType, "script") || 147 | strings.Contains(mimeType, "source") || 148 | strings.Contains(mimeType, "code")) { 149 | return true 150 | } 151 | 152 | return false 153 | } 154 | 155 | // isImageFile determines if a file is an image based on MIME type 156 | func isImageFile(mimeType string) bool { 157 | return strings.HasPrefix(mimeType, "image/") || 158 | (mimeType == "application/xml" && strings.HasSuffix(strings.ToLower(mimeType), ".svg")) 159 | } 160 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/delete_file_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestHandleDeleteFile(t *testing.T) { 15 | // Setup a temporary directory for the test 16 | tmpDir := t.TempDir() 17 | 18 | // Create a handler with the temp dir as an allowed path 19 | allowedDirs := resolveAllowedDirs(t, tmpDir) 20 | fsHandler, err := NewFilesystemHandler(allowedDirs) 21 | require.NoError(t, err) 22 | 23 | ctx := context.Background() 24 | 25 | t.Run("delete a file", func(t *testing.T) { 26 | filePath := filepath.Join(tmpDir, "test_file.txt") 27 | err := os.WriteFile(filePath, []byte("test content"), 0644) 28 | require.NoError(t, err) 29 | 30 | // Verify file exists before deletion 31 | _, err = os.Stat(filePath) 32 | require.NoError(t, err) 33 | 34 | req := mcp.CallToolRequest{ 35 | Params: mcp.CallToolParams{ 36 | Arguments: map[string]interface{}{ 37 | "path": filePath, 38 | }, 39 | }, 40 | } 41 | 42 | res, err := fsHandler.HandleDeleteFile(ctx, req) 43 | require.NoError(t, err) 44 | require.False(t, res.IsError) 45 | 46 | // Verify file was deleted 47 | _, err = os.Stat(filePath) 48 | assert.True(t, os.IsNotExist(err)) 49 | }) 50 | 51 | t.Run("delete an empty directory with recursive=true", func(t *testing.T) { 52 | dirPath := filepath.Join(tmpDir, "empty_directory") 53 | err := os.Mkdir(dirPath, 0755) 54 | require.NoError(t, err) 55 | 56 | // Verify directory exists before deletion 57 | _, err = os.Stat(dirPath) 58 | require.NoError(t, err) 59 | 60 | req := mcp.CallToolRequest{ 61 | Params: mcp.CallToolParams{ 62 | Arguments: map[string]interface{}{ 63 | "path": dirPath, 64 | "recursive": true, 65 | }, 66 | }, 67 | } 68 | 69 | res, err := fsHandler.HandleDeleteFile(ctx, req) 70 | require.NoError(t, err) 71 | require.False(t, res.IsError) 72 | 73 | // Verify directory was deleted 74 | _, err = os.Stat(dirPath) 75 | assert.True(t, os.IsNotExist(err)) 76 | }) 77 | 78 | t.Run("delete a directory with contents using recursive=true", func(t *testing.T) { 79 | dirPath := filepath.Join(tmpDir, "directory_with_contents") 80 | err := os.Mkdir(dirPath, 0755) 81 | require.NoError(t, err) 82 | 83 | // Create a file inside the directory 84 | filePath := filepath.Join(dirPath, "nested_file.txt") 85 | err = os.WriteFile(filePath, []byte("nested content"), 0644) 86 | require.NoError(t, err) 87 | 88 | // Create a subdirectory 89 | subDirPath := filepath.Join(dirPath, "subdirectory") 90 | err = os.Mkdir(subDirPath, 0755) 91 | require.NoError(t, err) 92 | 93 | // Verify directory and contents exist before deletion 94 | _, err = os.Stat(dirPath) 95 | require.NoError(t, err) 96 | _, err = os.Stat(filePath) 97 | require.NoError(t, err) 98 | _, err = os.Stat(subDirPath) 99 | require.NoError(t, err) 100 | 101 | req := mcp.CallToolRequest{ 102 | Params: mcp.CallToolParams{ 103 | Arguments: map[string]interface{}{ 104 | "path": dirPath, 105 | "recursive": true, 106 | }, 107 | }, 108 | } 109 | 110 | res, err := fsHandler.HandleDeleteFile(ctx, req) 111 | require.NoError(t, err) 112 | require.False(t, res.IsError) 113 | 114 | // Verify directory and all contents were deleted 115 | _, err = os.Stat(dirPath) 116 | assert.True(t, os.IsNotExist(err)) 117 | }) 118 | 119 | t.Run("try to delete directory without recursive flag", func(t *testing.T) { 120 | dirPath := filepath.Join(tmpDir, "directory_no_recursive") 121 | err := os.Mkdir(dirPath, 0755) 122 | require.NoError(t, err) 123 | 124 | req := mcp.CallToolRequest{ 125 | Params: mcp.CallToolParams{ 126 | Arguments: map[string]interface{}{ 127 | "path": dirPath, 128 | }, 129 | }, 130 | } 131 | 132 | res, err := fsHandler.HandleDeleteFile(ctx, req) 133 | require.NoError(t, err) 134 | require.True(t, res.IsError) 135 | 136 | // Verify directory still exists 137 | _, err = os.Stat(dirPath) 138 | require.NoError(t, err) 139 | }) 140 | 141 | t.Run("try to delete non-existent file", func(t *testing.T) { 142 | nonExistentPath := filepath.Join(tmpDir, "non_existent_file.txt") 143 | 144 | req := mcp.CallToolRequest{ 145 | Params: mcp.CallToolParams{ 146 | Arguments: map[string]interface{}{ 147 | "path": nonExistentPath, 148 | }, 149 | }, 150 | } 151 | 152 | res, err := fsHandler.HandleDeleteFile(ctx, req) 153 | require.NoError(t, err) 154 | require.True(t, res.IsError) 155 | }) 156 | 157 | t.Run("path is in a non-allowed directory", func(t *testing.T) { 158 | otherDir := t.TempDir() 159 | 160 | req := mcp.CallToolRequest{ 161 | Params: mcp.CallToolParams{ 162 | Arguments: map[string]interface{}{ 163 | "path": filepath.Join(otherDir, "some_file.txt"), 164 | }, 165 | }, 166 | } 167 | 168 | res, err := fsHandler.HandleDeleteFile(ctx, req) 169 | require.NoError(t, err) 170 | require.True(t, res.IsError) 171 | }) 172 | } 173 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/read_multiple_files.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | func (fs *FilesystemHandler) HandleReadMultipleFiles( 13 | ctx context.Context, 14 | request mcp.CallToolRequest, 15 | ) (*mcp.CallToolResult, error) { 16 | pathsSlice, err := request.RequireStringSlice("paths") 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | if len(pathsSlice) == 0 { 22 | return &mcp.CallToolResult{ 23 | Content: []mcp.Content{ 24 | mcp.TextContent{ 25 | Type: "text", 26 | Text: "No files specified to read", 27 | }, 28 | }, 29 | IsError: true, 30 | }, nil 31 | } 32 | 33 | // Maximum number of files to read in a single request 34 | const maxFiles = 50 35 | if len(pathsSlice) > maxFiles { 36 | return &mcp.CallToolResult{ 37 | Content: []mcp.Content{ 38 | mcp.TextContent{ 39 | Type: "text", 40 | Text: fmt.Sprintf("Too many files requested. Maximum is %d files per request.", maxFiles), 41 | }, 42 | }, 43 | IsError: true, 44 | }, nil 45 | } 46 | 47 | // Process each file 48 | var results []mcp.Content 49 | for _, path := range pathsSlice { 50 | // Handle empty or relative paths like "." or "./" by converting to absolute path 51 | if path == "." || path == "./" { 52 | // Get current working directory 53 | cwd, err := os.Getwd() 54 | if err != nil { 55 | results = append(results, mcp.TextContent{ 56 | Type: "text", 57 | Text: fmt.Sprintf("Error resolving current directory for path '%s': %v", path, err), 58 | }) 59 | continue 60 | } 61 | path = cwd 62 | } 63 | 64 | validPath, err := fs.validatePath(path) 65 | if err != nil { 66 | results = append(results, mcp.TextContent{ 67 | Type: "text", 68 | Text: fmt.Sprintf("Error with path '%s': %v", path, err), 69 | }) 70 | continue 71 | } 72 | 73 | // Check if it's a directory 74 | info, err := os.Stat(validPath) 75 | if err != nil { 76 | results = append(results, mcp.TextContent{ 77 | Type: "text", 78 | Text: fmt.Sprintf("Error accessing '%s': %v", path, err), 79 | }) 80 | continue 81 | } 82 | 83 | if info.IsDir() { 84 | // For directories, return a resource reference instead 85 | resourceURI := pathToResourceURI(validPath) 86 | results = append(results, mcp.TextContent{ 87 | Type: "text", 88 | Text: fmt.Sprintf("'%s' is a directory. Use list_directory tool or resource URI: %s", path, resourceURI), 89 | }) 90 | continue 91 | } 92 | 93 | // Determine MIME type 94 | mimeType := detectMimeType(validPath) 95 | 96 | // Check file size 97 | if info.Size() > MAX_INLINE_SIZE { 98 | // File is too large to inline, return a resource reference 99 | resourceURI := pathToResourceURI(validPath) 100 | results = append(results, mcp.TextContent{ 101 | Type: "text", 102 | Text: fmt.Sprintf("File '%s' is too large to display inline (%d bytes). Access it via resource URI: %s", 103 | path, info.Size(), resourceURI), 104 | }) 105 | continue 106 | } 107 | 108 | // Read file content 109 | content, err := os.ReadFile(validPath) 110 | if err != nil { 111 | results = append(results, mcp.TextContent{ 112 | Type: "text", 113 | Text: fmt.Sprintf("Error reading file '%s': %v", path, err), 114 | }) 115 | continue 116 | } 117 | 118 | // Add file header 119 | results = append(results, mcp.TextContent{ 120 | Type: "text", 121 | Text: fmt.Sprintf("--- File: %s ---", path), 122 | }) 123 | 124 | // Check if it's a text file 125 | if isTextFile(mimeType) { 126 | // It's a text file, return as text 127 | results = append(results, mcp.TextContent{ 128 | Type: "text", 129 | Text: string(content), 130 | }) 131 | } else if isImageFile(mimeType) { 132 | // It's an image file, return as image content 133 | if info.Size() <= MAX_BASE64_SIZE { 134 | results = append(results, mcp.TextContent{ 135 | Type: "text", 136 | Text: fmt.Sprintf("Image file: %s (%s, %d bytes)", path, mimeType, info.Size()), 137 | }) 138 | results = append(results, mcp.ImageContent{ 139 | Type: "image", 140 | Data: base64.StdEncoding.EncodeToString(content), 141 | MIMEType: mimeType, 142 | }) 143 | } else { 144 | // Too large for base64, return a reference 145 | resourceURI := pathToResourceURI(validPath) 146 | results = append(results, mcp.TextContent{ 147 | Type: "text", 148 | Text: fmt.Sprintf("Image file '%s' is too large to display inline (%d bytes). Access it via resource URI: %s", 149 | path, info.Size(), resourceURI), 150 | }) 151 | } 152 | } else { 153 | // It's another type of binary file 154 | resourceURI := pathToResourceURI(validPath) 155 | 156 | if info.Size() <= MAX_BASE64_SIZE { 157 | // Small enough for base64 encoding 158 | results = append(results, mcp.TextContent{ 159 | Type: "text", 160 | Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", path, mimeType, info.Size()), 161 | }) 162 | results = append(results, mcp.EmbeddedResource{ 163 | Type: "resource", 164 | Resource: mcp.BlobResourceContents{ 165 | URI: resourceURI, 166 | MIMEType: mimeType, 167 | Blob: base64.StdEncoding.EncodeToString(content), 168 | }, 169 | }) 170 | } else { 171 | // Too large for base64, return a reference 172 | results = append(results, mcp.TextContent{ 173 | Type: "text", 174 | Text: fmt.Sprintf("Binary file '%s' (%s, %d bytes). Access it via resource URI: %s", 175 | path, mimeType, info.Size(), resourceURI), 176 | }) 177 | } 178 | } 179 | } 180 | 181 | return &mcp.CallToolResult{ 182 | Content: results, 183 | }, nil 184 | } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/tree.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/mark3labs/mcp-go/mcp" 11 | ) 12 | 13 | func (fs *FilesystemHandler) HandleTree( 14 | ctx context.Context, 15 | request mcp.CallToolRequest, 16 | ) (*mcp.CallToolResult, error) { 17 | path, err := request.RequireString("path") 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | // Handle empty or relative paths like "." or "./" by converting to absolute path 23 | if path == "." || path == "./" { 24 | // Get current working directory 25 | cwd, err := os.Getwd() 26 | if err != nil { 27 | return &mcp.CallToolResult{ 28 | Content: []mcp.Content{ 29 | mcp.TextContent{ 30 | Type: "text", 31 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 32 | }, 33 | }, 34 | IsError: true, 35 | }, nil 36 | } 37 | path = cwd 38 | } 39 | 40 | // Extract depth parameter (optional, default: 3) 41 | depth := 3 // Default value 42 | if depthParam, err := request.RequireFloat("depth"); err == nil { 43 | depth = int(depthParam) 44 | } 45 | 46 | // Extract follow_symlinks parameter (optional, default: false) 47 | followSymlinks := false // Default value 48 | if followParam, err := request.RequireBool("follow_symlinks"); err == nil { 49 | followSymlinks = followParam 50 | } 51 | 52 | // Validate the path is within allowed directories 53 | validPath, err := fs.validatePath(path) 54 | if err != nil { 55 | return &mcp.CallToolResult{ 56 | Content: []mcp.Content{ 57 | mcp.TextContent{ 58 | Type: "text", 59 | Text: fmt.Sprintf("Error: %v", err), 60 | }, 61 | }, 62 | IsError: true, 63 | }, nil 64 | } 65 | 66 | // Check if it's a directory 67 | info, err := os.Stat(validPath) 68 | if err != nil { 69 | return &mcp.CallToolResult{ 70 | Content: []mcp.Content{ 71 | mcp.TextContent{ 72 | Type: "text", 73 | Text: fmt.Sprintf("Error: %v", err), 74 | }, 75 | }, 76 | IsError: true, 77 | }, nil 78 | } 79 | 80 | if !info.IsDir() { 81 | return &mcp.CallToolResult{ 82 | Content: []mcp.Content{ 83 | mcp.TextContent{ 84 | Type: "text", 85 | Text: "Error: The specified path is not a directory", 86 | }, 87 | }, 88 | IsError: true, 89 | }, nil 90 | } 91 | 92 | // Build the tree structure 93 | tree, err := fs.buildTree(validPath, depth, 0, followSymlinks) 94 | if err != nil { 95 | return &mcp.CallToolResult{ 96 | Content: []mcp.Content{ 97 | mcp.TextContent{ 98 | Type: "text", 99 | Text: fmt.Sprintf("Error building directory tree: %v", err), 100 | }, 101 | }, 102 | IsError: true, 103 | }, nil 104 | } 105 | 106 | // Convert to JSON 107 | jsonData, err := json.MarshalIndent(tree, "", " ") 108 | if err != nil { 109 | return &mcp.CallToolResult{ 110 | Content: []mcp.Content{ 111 | mcp.TextContent{ 112 | Type: "text", 113 | Text: fmt.Sprintf("Error generating JSON: %v", err), 114 | }, 115 | }, 116 | IsError: true, 117 | }, nil 118 | } 119 | 120 | // Create resource URI for the directory 121 | resourceURI := pathToResourceURI(validPath) 122 | 123 | // Return the result 124 | return &mcp.CallToolResult{ 125 | Content: []mcp.Content{ 126 | mcp.TextContent{ 127 | Type: "text", 128 | Text: fmt.Sprintf("Directory tree for %s (max depth: %d):\n\n%s", validPath, depth, string(jsonData)), 129 | }, 130 | mcp.EmbeddedResource{ 131 | Type: "resource", 132 | Resource: mcp.TextResourceContents{ 133 | URI: resourceURI, 134 | MIMEType: "application/json", 135 | Text: string(jsonData), 136 | }, 137 | }, 138 | }, 139 | }, nil 140 | } 141 | 142 | // buildTree builds a tree representation of the filesystem starting at the given path 143 | func (fs *FilesystemHandler) buildTree(path string, maxDepth int, currentDepth int, followSymlinks bool) (*FileNode, error) { 144 | // Validate the path 145 | validPath, err := fs.validatePath(path) 146 | if err != nil { 147 | return nil, err 148 | } 149 | 150 | // Get file info 151 | info, err := os.Stat(validPath) 152 | if err != nil { 153 | return nil, err 154 | } 155 | 156 | // Create the node 157 | node := &FileNode{ 158 | Name: filepath.Base(validPath), 159 | Path: validPath, 160 | Modified: info.ModTime(), 161 | } 162 | 163 | // Set type and size 164 | if info.IsDir() { 165 | node.Type = "directory" 166 | 167 | // If we haven't reached the max depth, process children 168 | if currentDepth < maxDepth { 169 | // Read directory entries 170 | entries, err := os.ReadDir(validPath) 171 | if err != nil { 172 | return nil, err 173 | } 174 | 175 | // Process each entry 176 | for _, entry := range entries { 177 | entryPath := filepath.Join(validPath, entry.Name()) 178 | 179 | // Handle symlinks 180 | if entry.Type()&os.ModeSymlink != 0 { 181 | if !followSymlinks { 182 | // Skip symlinks if not following them 183 | continue 184 | } 185 | 186 | // Resolve symlink 187 | linkDest, err := filepath.EvalSymlinks(entryPath) 188 | if err != nil { 189 | // Skip invalid symlinks 190 | continue 191 | } 192 | 193 | // Validate the symlink destination is within allowed directories 194 | if !fs.isPathInAllowedDirs(linkDest) { 195 | // Skip symlinks pointing outside allowed directories 196 | continue 197 | } 198 | 199 | entryPath = linkDest 200 | } 201 | 202 | // Recursively build child node 203 | childNode, err := fs.buildTree(entryPath, maxDepth, currentDepth+1, followSymlinks) 204 | if err != nil { 205 | // Skip entries with errors 206 | continue 207 | } 208 | 209 | // Add child to the current node 210 | node.Children = append(node.Children, childNode) 211 | } 212 | } 213 | } else { 214 | node.Type = "file" 215 | node.Size = info.Size() 216 | } 217 | 218 | return node, nil 219 | } 220 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/modify_file.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/mark3labs/mcp-go/mcp" 11 | ) 12 | 13 | // handleModifyFile handles the modify_file tool request 14 | func (fs *FilesystemHandler) HandleModifyFile( 15 | ctx context.Context, 16 | request mcp.CallToolRequest, 17 | ) (*mcp.CallToolResult, error) { 18 | // Extract arguments 19 | path, err := request.RequireString("path") 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | find, err := request.RequireString("find") 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | replace, err := request.RequireString("replace") 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | // Extract optional arguments with defaults 35 | allOccurrences := true // Default value 36 | if val, err := request.RequireBool("all_occurrences"); err == nil { 37 | allOccurrences = val 38 | } 39 | 40 | useRegex := false // Default value 41 | if val, err := request.RequireBool("regex"); err == nil { 42 | useRegex = val 43 | } 44 | 45 | // Handle empty or relative paths like "." or "./" by converting to absolute path 46 | if path == "." || path == "./" { 47 | // Get current working directory 48 | cwd, err := os.Getwd() 49 | if err != nil { 50 | return &mcp.CallToolResult{ 51 | Content: []mcp.Content{ 52 | mcp.TextContent{ 53 | Type: "text", 54 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 55 | }, 56 | }, 57 | IsError: true, 58 | }, nil 59 | } 60 | path = cwd 61 | } 62 | 63 | // Validate path is within allowed directories 64 | validPath, err := fs.validatePath(path) 65 | if err != nil { 66 | return &mcp.CallToolResult{ 67 | Content: []mcp.Content{ 68 | mcp.TextContent{ 69 | Type: "text", 70 | Text: fmt.Sprintf("Error: %v", err), 71 | }, 72 | }, 73 | IsError: true, 74 | }, nil 75 | } 76 | 77 | // Check if it's a directory 78 | if info, err := os.Stat(validPath); err == nil && info.IsDir() { 79 | return &mcp.CallToolResult{ 80 | Content: []mcp.Content{ 81 | mcp.TextContent{ 82 | Type: "text", 83 | Text: "Error: Cannot modify a directory", 84 | }, 85 | }, 86 | IsError: true, 87 | }, nil 88 | } 89 | 90 | // Check if file exists 91 | if _, err := os.Stat(validPath); os.IsNotExist(err) { 92 | return &mcp.CallToolResult{ 93 | Content: []mcp.Content{ 94 | mcp.TextContent{ 95 | Type: "text", 96 | Text: fmt.Sprintf("Error: File not found: %s", path), 97 | }, 98 | }, 99 | IsError: true, 100 | }, nil 101 | } 102 | 103 | // Read file content 104 | content, err := os.ReadFile(validPath) 105 | if err != nil { 106 | return &mcp.CallToolResult{ 107 | Content: []mcp.Content{ 108 | mcp.TextContent{ 109 | Type: "text", 110 | Text: fmt.Sprintf("Error reading file: %v", err), 111 | }, 112 | }, 113 | IsError: true, 114 | }, nil 115 | } 116 | 117 | originalContent := string(content) 118 | modifiedContent := "" 119 | replacementCount := 0 120 | 121 | // Perform the replacement 122 | if useRegex { 123 | re, err := regexp.Compile(find) 124 | if err != nil { 125 | return &mcp.CallToolResult{ 126 | Content: []mcp.Content{ 127 | mcp.TextContent{ 128 | Type: "text", 129 | Text: fmt.Sprintf("Error: Invalid regular expression: %v", err), 130 | }, 131 | }, 132 | IsError: true, 133 | }, nil 134 | } 135 | 136 | if allOccurrences { 137 | modifiedContent = re.ReplaceAllString(originalContent, replace) 138 | replacementCount = len(re.FindAllString(originalContent, -1)) 139 | } else { 140 | matched := re.FindStringIndex(originalContent) 141 | if matched != nil { 142 | replacementCount = 1 143 | modifiedContent = originalContent[:matched[0]] + replace + originalContent[matched[1]:] 144 | } else { 145 | modifiedContent = originalContent 146 | replacementCount = 0 147 | } 148 | } 149 | } else { 150 | if allOccurrences { 151 | replacementCount = strings.Count(originalContent, find) 152 | modifiedContent = strings.ReplaceAll(originalContent, find, replace) 153 | } else { 154 | if index := strings.Index(originalContent, find); index != -1 { 155 | replacementCount = 1 156 | modifiedContent = originalContent[:index] + replace + originalContent[index+len(find):] 157 | } else { 158 | modifiedContent = originalContent 159 | replacementCount = 0 160 | } 161 | } 162 | } 163 | 164 | // Write modified content back to file 165 | if err := os.WriteFile(validPath, []byte(modifiedContent), 0644); err != nil { 166 | return &mcp.CallToolResult{ 167 | Content: []mcp.Content{ 168 | mcp.TextContent{ 169 | Type: "text", 170 | Text: fmt.Sprintf("Error writing to file: %v", err), 171 | }, 172 | }, 173 | IsError: true, 174 | }, nil 175 | } 176 | 177 | // Create response 178 | resourceURI := pathToResourceURI(validPath) 179 | 180 | // Get file info for the response 181 | info, err := os.Stat(validPath) 182 | if err != nil { 183 | // File was written but we couldn't get info 184 | return &mcp.CallToolResult{ 185 | Content: []mcp.Content{ 186 | mcp.TextContent{ 187 | Type: "text", 188 | Text: fmt.Sprintf("File modified successfully. Made %d replacement(s).", replacementCount), 189 | }, 190 | }, 191 | }, nil 192 | } 193 | 194 | return &mcp.CallToolResult{ 195 | Content: []mcp.Content{ 196 | mcp.TextContent{ 197 | Type: "text", 198 | Text: fmt.Sprintf("File modified successfully. Made %d replacement(s) in %s (file size: %d bytes)", 199 | replacementCount, path, info.Size()), 200 | }, 201 | mcp.EmbeddedResource{ 202 | Type: "resource", 203 | Resource: mcp.TextResourceContents{ 204 | URI: resourceURI, 205 | MIMEType: "text/plain", 206 | Text: fmt.Sprintf("Modified file: %s (%d bytes)", validPath, info.Size()), 207 | }, 208 | }, 209 | }, 210 | }, nil 211 | } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/copy_file.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/mark3labs/mcp-go/mcp" 11 | ) 12 | 13 | func (fs *FilesystemHandler) HandleCopyFile( 14 | ctx context.Context, 15 | request mcp.CallToolRequest, 16 | ) (*mcp.CallToolResult, error) { 17 | source, err := request.RequireString("source") 18 | if err != nil { 19 | return nil, err 20 | } 21 | destination, err := request.RequireString("destination") 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | // Handle empty or relative paths for source 27 | if source == "." || source == "./" { 28 | cwd, err := os.Getwd() 29 | if err != nil { 30 | return &mcp.CallToolResult{ 31 | Content: []mcp.Content{ 32 | mcp.TextContent{ 33 | Type: "text", 34 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 35 | }, 36 | }, 37 | IsError: true, 38 | }, nil 39 | } 40 | source = cwd 41 | } 42 | if destination == "." || destination == "./" { 43 | cwd, err := os.Getwd() 44 | if err != nil { 45 | return &mcp.CallToolResult{ 46 | Content: []mcp.Content{ 47 | mcp.TextContent{ 48 | Type: "text", 49 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 50 | }, 51 | }, 52 | IsError: true, 53 | }, nil 54 | } 55 | destination = cwd 56 | } 57 | 58 | validSource, err := fs.validatePath(source) 59 | if err != nil { 60 | return &mcp.CallToolResult{ 61 | Content: []mcp.Content{ 62 | mcp.TextContent{ 63 | Type: "text", 64 | Text: fmt.Sprintf("Error with source path: %v", err), 65 | }, 66 | }, 67 | IsError: true, 68 | }, nil 69 | } 70 | 71 | // Check if source exists 72 | srcInfo, err := os.Stat(validSource) 73 | if os.IsNotExist(err) { 74 | return &mcp.CallToolResult{ 75 | Content: []mcp.Content{ 76 | mcp.TextContent{ 77 | Type: "text", 78 | Text: fmt.Sprintf("Error: Source does not exist: %s", source), 79 | }, 80 | }, 81 | IsError: true, 82 | }, nil 83 | } else if err != nil { 84 | return &mcp.CallToolResult{ 85 | Content: []mcp.Content{ 86 | mcp.TextContent{ 87 | Type: "text", 88 | Text: fmt.Sprintf("Error accessing source: %v", err), 89 | }, 90 | }, 91 | IsError: true, 92 | }, nil 93 | } 94 | 95 | validDest, err := fs.validatePath(destination) 96 | if err != nil { 97 | return &mcp.CallToolResult{ 98 | Content: []mcp.Content{ 99 | mcp.TextContent{ 100 | Type: "text", 101 | Text: fmt.Sprintf("Error with destination path: %v", err), 102 | }, 103 | }, 104 | IsError: true, 105 | }, nil 106 | } 107 | 108 | // Create parent directory for destination if it doesn't exist 109 | destDir := filepath.Dir(validDest) 110 | if err := os.MkdirAll(destDir, 0755); err != nil { 111 | return &mcp.CallToolResult{ 112 | Content: []mcp.Content{ 113 | mcp.TextContent{ 114 | Type: "text", 115 | Text: fmt.Sprintf("Error creating destination directory: %v", err), 116 | }, 117 | }, 118 | IsError: true, 119 | }, nil 120 | } 121 | 122 | // Perform the copy operation based on whether source is a file or directory 123 | if srcInfo.IsDir() { 124 | // It's a directory, copy recursively 125 | if err := copyDir(validSource, validDest); err != nil { 126 | return &mcp.CallToolResult{ 127 | Content: []mcp.Content{ 128 | mcp.TextContent{ 129 | Type: "text", 130 | Text: fmt.Sprintf("Error copying directory: %v", err), 131 | }, 132 | }, 133 | IsError: true, 134 | }, nil 135 | } 136 | } else { 137 | // It's a file, copy directly 138 | if err := copyFile(validSource, validDest); err != nil { 139 | return &mcp.CallToolResult{ 140 | Content: []mcp.Content{ 141 | mcp.TextContent{ 142 | Type: "text", 143 | Text: fmt.Sprintf("Error copying file: %v", err), 144 | }, 145 | }, 146 | IsError: true, 147 | }, nil 148 | } 149 | } 150 | 151 | resourceURI := pathToResourceURI(validDest) 152 | return &mcp.CallToolResult{ 153 | Content: []mcp.Content{ 154 | mcp.TextContent{ 155 | Type: "text", 156 | Text: fmt.Sprintf( 157 | "Successfully copied %s to %s", 158 | source, 159 | destination, 160 | ), 161 | }, 162 | mcp.EmbeddedResource{ 163 | Type: "resource", 164 | Resource: mcp.TextResourceContents{ 165 | URI: resourceURI, 166 | MIMEType: "text/plain", 167 | Text: fmt.Sprintf("Copied file: %s", validDest), 168 | }, 169 | }, 170 | }, 171 | }, nil 172 | } 173 | 174 | // copyFile copies a single file from src to dst 175 | func copyFile(src, dst string) error { 176 | // Open the source file 177 | sourceFile, err := os.Open(src) 178 | if err != nil { 179 | return err 180 | } 181 | defer sourceFile.Close() 182 | 183 | // Create the destination file 184 | destFile, err := os.Create(dst) 185 | if err != nil { 186 | return err 187 | } 188 | defer destFile.Close() 189 | 190 | // Copy the contents 191 | if _, err := io.Copy(destFile, sourceFile); err != nil { 192 | return err 193 | } 194 | 195 | // Get source file mode 196 | sourceInfo, err := os.Stat(src) 197 | if err != nil { 198 | return err 199 | } 200 | 201 | // Set the same file mode on destination 202 | return os.Chmod(dst, sourceInfo.Mode()) 203 | } 204 | 205 | // copyDir recursively copies a directory tree from src to dst 206 | func copyDir(src, dst string) error { 207 | // Get properties of source dir 208 | srcInfo, err := os.Stat(src) 209 | if err != nil { 210 | return err 211 | } 212 | 213 | // Create the destination directory with the same permissions 214 | if err = os.MkdirAll(dst, srcInfo.Mode()); err != nil { 215 | return err 216 | } 217 | 218 | // Read directory entries 219 | entries, err := os.ReadDir(src) 220 | if err != nil { 221 | return err 222 | } 223 | 224 | for _, entry := range entries { 225 | srcPath := filepath.Join(src, entry.Name()) 226 | dstPath := filepath.Join(dst, entry.Name()) 227 | 228 | // Handle symlinks 229 | if entry.Type()&os.ModeSymlink != 0 { 230 | // For simplicity, we'll skip symlinks in this implementation 231 | continue 232 | } 233 | 234 | // Recursively copy subdirectories or copy files 235 | if entry.IsDir() { 236 | if err = copyDir(srcPath, dstPath); err != nil { 237 | return err 238 | } 239 | } else { 240 | if err = copyFile(srcPath, dstPath); err != nil { 241 | return err 242 | } 243 | } 244 | } 245 | 246 | return nil 247 | } ``` -------------------------------------------------------------------------------- /filesystemserver/handler/read_file.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/mark3labs/mcp-go/mcp" 10 | ) 11 | 12 | func (fs *FilesystemHandler) HandleReadFile( 13 | ctx context.Context, 14 | request mcp.CallToolRequest, 15 | ) (*mcp.CallToolResult, error) { 16 | path, err := request.RequireString("path") 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | // Handle empty or relative paths like "." or "./" by converting to absolute path 22 | if path == "." || path == "./" { 23 | // Get current working directory 24 | cwd, err := os.Getwd() 25 | if err != nil { 26 | return &mcp.CallToolResult{ 27 | Content: []mcp.Content{ 28 | mcp.TextContent{ 29 | Type: "text", 30 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 31 | }, 32 | }, 33 | IsError: true, 34 | }, nil 35 | } 36 | path = cwd 37 | } 38 | 39 | validPath, err := fs.validatePath(path) 40 | if err != nil { 41 | return &mcp.CallToolResult{ 42 | Content: []mcp.Content{ 43 | mcp.TextContent{ 44 | Type: "text", 45 | Text: fmt.Sprintf("Error: %v", err), 46 | }, 47 | }, 48 | IsError: true, 49 | }, nil 50 | } 51 | 52 | // Check if it's a directory 53 | info, err := os.Stat(validPath) 54 | if err != nil { 55 | return &mcp.CallToolResult{ 56 | Content: []mcp.Content{ 57 | mcp.TextContent{ 58 | Type: "text", 59 | Text: fmt.Sprintf("Error: %v", err), 60 | }, 61 | }, 62 | IsError: true, 63 | }, nil 64 | } 65 | 66 | if info.IsDir() { 67 | // For directories, return a resource reference instead 68 | resourceURI := pathToResourceURI(validPath) 69 | return &mcp.CallToolResult{ 70 | Content: []mcp.Content{ 71 | mcp.TextContent{ 72 | Type: "text", 73 | Text: fmt.Sprintf("This is a directory. Use the resource URI to browse its contents: %s", resourceURI), 74 | }, 75 | mcp.EmbeddedResource{ 76 | Type: "resource", 77 | Resource: mcp.TextResourceContents{ 78 | URI: resourceURI, 79 | MIMEType: "text/plain", 80 | Text: fmt.Sprintf("Directory: %s", validPath), 81 | }, 82 | }, 83 | }, 84 | }, nil 85 | } 86 | 87 | // Determine MIME type 88 | mimeType := detectMimeType(validPath) 89 | 90 | // Check file size 91 | if info.Size() > MAX_INLINE_SIZE { 92 | // File is too large to inline, return a resource reference 93 | resourceURI := pathToResourceURI(validPath) 94 | return &mcp.CallToolResult{ 95 | Content: []mcp.Content{ 96 | mcp.TextContent{ 97 | Type: "text", 98 | Text: fmt.Sprintf("File is too large to display inline (%d bytes). Access it via resource URI: %s", info.Size(), resourceURI), 99 | }, 100 | mcp.EmbeddedResource{ 101 | Type: "resource", 102 | Resource: mcp.TextResourceContents{ 103 | URI: resourceURI, 104 | MIMEType: "text/plain", 105 | Text: fmt.Sprintf("Large file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), 106 | }, 107 | }, 108 | }, 109 | }, nil 110 | } 111 | 112 | // Read file content 113 | content, err := os.ReadFile(validPath) 114 | if err != nil { 115 | return &mcp.CallToolResult{ 116 | Content: []mcp.Content{ 117 | mcp.TextContent{ 118 | Type: "text", 119 | Text: fmt.Sprintf("Error reading file: %v", err), 120 | }, 121 | }, 122 | IsError: true, 123 | }, nil 124 | } 125 | 126 | // Check if it's a text file 127 | if isTextFile(mimeType) { 128 | // It's a text file, return as text 129 | return &mcp.CallToolResult{ 130 | Content: []mcp.Content{ 131 | mcp.TextContent{ 132 | Type: "text", 133 | Text: string(content), 134 | }, 135 | }, 136 | }, nil 137 | } else if isImageFile(mimeType) { 138 | // It's an image file, return as image content 139 | if info.Size() <= MAX_BASE64_SIZE { 140 | return &mcp.CallToolResult{ 141 | Content: []mcp.Content{ 142 | mcp.TextContent{ 143 | Type: "text", 144 | Text: fmt.Sprintf("Image file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), 145 | }, 146 | mcp.ImageContent{ 147 | Type: "image", 148 | Data: base64.StdEncoding.EncodeToString(content), 149 | MIMEType: mimeType, 150 | }, 151 | }, 152 | }, nil 153 | } else { 154 | // Too large for base64, return a reference 155 | resourceURI := pathToResourceURI(validPath) 156 | return &mcp.CallToolResult{ 157 | Content: []mcp.Content{ 158 | mcp.TextContent{ 159 | Type: "text", 160 | Text: fmt.Sprintf("Image file is too large to display inline (%d bytes). Access it via resource URI: %s", info.Size(), resourceURI), 161 | }, 162 | mcp.EmbeddedResource{ 163 | Type: "resource", 164 | Resource: mcp.TextResourceContents{ 165 | URI: resourceURI, 166 | MIMEType: "text/plain", 167 | Text: fmt.Sprintf("Large image: %s (%s, %d bytes)", validPath, mimeType, info.Size()), 168 | }, 169 | }, 170 | }, 171 | }, nil 172 | } 173 | } else { 174 | // It's another type of binary file 175 | resourceURI := pathToResourceURI(validPath) 176 | 177 | if info.Size() <= MAX_BASE64_SIZE { 178 | // Small enough for base64 encoding 179 | return &mcp.CallToolResult{ 180 | Content: []mcp.Content{ 181 | mcp.TextContent{ 182 | Type: "text", 183 | Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), 184 | }, 185 | mcp.EmbeddedResource{ 186 | Type: "resource", 187 | Resource: mcp.BlobResourceContents{ 188 | URI: resourceURI, 189 | MIMEType: mimeType, 190 | Blob: base64.StdEncoding.EncodeToString(content), 191 | }, 192 | }, 193 | }, 194 | }, nil 195 | } else { 196 | // Too large for base64, return a reference 197 | return &mcp.CallToolResult{ 198 | Content: []mcp.Content{ 199 | mcp.TextContent{ 200 | Type: "text", 201 | Text: fmt.Sprintf("Binary file: %s (%s, %d bytes). Access it via resource URI: %s", validPath, mimeType, info.Size(), resourceURI), 202 | }, 203 | mcp.EmbeddedResource{ 204 | Type: "resource", 205 | Resource: mcp.TextResourceContents{ 206 | URI: resourceURI, 207 | MIMEType: "text/plain", 208 | Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), 209 | }, 210 | }, 211 | }, 212 | }, nil 213 | } 214 | } 215 | } ``` -------------------------------------------------------------------------------- /filesystemserver/server.go: -------------------------------------------------------------------------------- ```go 1 | package filesystemserver 2 | 3 | import ( 4 | "github.com/mark3labs/mcp-filesystem-server/filesystemserver/handler" 5 | "github.com/mark3labs/mcp-go/mcp" 6 | "github.com/mark3labs/mcp-go/server" 7 | ) 8 | 9 | var Version = "dev" 10 | 11 | func NewFilesystemServer(allowedDirs []string) (*server.MCPServer, error) { 12 | 13 | h, err := handler.NewFilesystemHandler(allowedDirs) 14 | if err != nil { 15 | return nil, err 16 | } 17 | 18 | s := server.NewMCPServer( 19 | "secure-filesystem-server", 20 | Version, 21 | server.WithResourceCapabilities(true, true), 22 | ) 23 | 24 | // Register resource handlers 25 | s.AddResource(mcp.NewResource( 26 | "file://", 27 | "File System", 28 | mcp.WithResourceDescription("Access to files and directories on the local file system"), 29 | ), h.HandleReadResource) 30 | 31 | // Register tool handlers 32 | s.AddTool(mcp.NewTool( 33 | "read_file", 34 | mcp.WithDescription("Read the complete contents of a file from the file system."), 35 | mcp.WithString("path", 36 | mcp.Description("Path to the file to read"), 37 | mcp.Required(), 38 | ), 39 | ), h.HandleReadFile) 40 | 41 | s.AddTool(mcp.NewTool( 42 | "write_file", 43 | mcp.WithDescription("Create a new file or overwrite an existing file with new content."), 44 | mcp.WithString("path", 45 | mcp.Description("Path where to write the file"), 46 | mcp.Required(), 47 | ), 48 | mcp.WithString("content", 49 | mcp.Description("Content to write to the file"), 50 | mcp.Required(), 51 | ), 52 | ), h.HandleWriteFile) 53 | 54 | s.AddTool(mcp.NewTool( 55 | "list_directory", 56 | mcp.WithDescription("Get a detailed listing of all files and directories in a specified path."), 57 | mcp.WithString("path", 58 | mcp.Description("Path of the directory to list"), 59 | mcp.Required(), 60 | ), 61 | ), h.HandleListDirectory) 62 | 63 | s.AddTool(mcp.NewTool( 64 | "create_directory", 65 | mcp.WithDescription("Create a new directory or ensure a directory exists."), 66 | mcp.WithString("path", 67 | mcp.Description("Path of the directory to create"), 68 | mcp.Required(), 69 | ), 70 | ), h.HandleCreateDirectory) 71 | 72 | s.AddTool(mcp.NewTool( 73 | "copy_file", 74 | mcp.WithDescription("Copy files and directories."), 75 | mcp.WithString("source", 76 | mcp.Description("Source path of the file or directory"), 77 | mcp.Required(), 78 | ), 79 | mcp.WithString("destination", 80 | mcp.Description("Destination path"), 81 | mcp.Required(), 82 | ), 83 | ), h.HandleCopyFile) 84 | 85 | s.AddTool(mcp.NewTool( 86 | "move_file", 87 | mcp.WithDescription("Move or rename files and directories."), 88 | mcp.WithString("source", 89 | mcp.Description("Source path of the file or directory"), 90 | mcp.Required(), 91 | ), 92 | mcp.WithString("destination", 93 | mcp.Description("Destination path"), 94 | mcp.Required(), 95 | ), 96 | ), h.HandleMoveFile) 97 | 98 | s.AddTool(mcp.NewTool( 99 | "search_files", 100 | mcp.WithDescription("Recursively search for files and directories matching a pattern."), 101 | mcp.WithString("path", 102 | mcp.Description("Starting path for the search"), 103 | mcp.Required(), 104 | ), 105 | mcp.WithString("pattern", 106 | mcp.Description("Search pattern to match against file names"), 107 | mcp.Required(), 108 | ), 109 | ), h.HandleSearchFiles) 110 | 111 | s.AddTool(mcp.NewTool( 112 | "get_file_info", 113 | mcp.WithDescription("Retrieve detailed metadata about a file or directory."), 114 | mcp.WithString("path", 115 | mcp.Description("Path to the file or directory"), 116 | mcp.Required(), 117 | ), 118 | ), h.HandleGetFileInfo) 119 | 120 | s.AddTool(mcp.NewTool( 121 | "list_allowed_directories", 122 | mcp.WithDescription("Returns the list of directories that this server is allowed to access."), 123 | ), h.HandleListAllowedDirectories) 124 | 125 | s.AddTool(mcp.NewTool( 126 | "read_multiple_files", 127 | mcp.WithDescription("Read the contents of multiple files in a single operation."), 128 | mcp.WithArray("paths", 129 | mcp.Description("List of file paths to read"), 130 | mcp.Required(), 131 | mcp.Items(map[string]any{"type": "string"}), 132 | ), 133 | ), h.HandleReadMultipleFiles) 134 | 135 | s.AddTool(mcp.NewTool( 136 | "tree", 137 | mcp.WithDescription("Returns a hierarchical JSON representation of a directory structure."), 138 | mcp.WithString("path", 139 | mcp.Description("Path of the directory to traverse"), 140 | mcp.Required(), 141 | ), 142 | mcp.WithNumber("depth", 143 | mcp.Description("Maximum depth to traverse (default: 3)"), 144 | ), 145 | mcp.WithBoolean("follow_symlinks", 146 | mcp.Description("Whether to follow symbolic links (default: false)"), 147 | ), 148 | ), h.HandleTree) 149 | 150 | s.AddTool(mcp.NewTool( 151 | "delete_file", 152 | mcp.WithDescription("Delete a file or directory from the file system."), 153 | mcp.WithString("path", 154 | mcp.Description("Path to the file or directory to delete"), 155 | mcp.Required(), 156 | ), 157 | mcp.WithBoolean("recursive", 158 | mcp.Description("Whether to recursively delete directories (default: false)"), 159 | ), 160 | ), h.HandleDeleteFile) 161 | 162 | s.AddTool(mcp.NewTool( 163 | "modify_file", 164 | mcp.WithDescription("Update file by finding and replacing text. Provides a simple pattern matching interface without needing exact character positions."), 165 | mcp.WithString("path", 166 | mcp.Description("Path to the file to modify"), 167 | mcp.Required(), 168 | ), 169 | mcp.WithString("find", 170 | mcp.Description("Text to search for (exact match or regex pattern)"), 171 | mcp.Required(), 172 | ), 173 | mcp.WithString("replace", 174 | mcp.Description("Text to replace with"), 175 | mcp.Required(), 176 | ), 177 | mcp.WithBoolean("all_occurrences", 178 | mcp.Description("Replace all occurrences of the matching text (default: true)"), 179 | ), 180 | mcp.WithBoolean("regex", 181 | mcp.Description("Treat the find pattern as a regular expression (default: false)"), 182 | ), 183 | ), h.HandleModifyFile) 184 | 185 | s.AddTool(mcp.NewTool( 186 | "search_within_files", 187 | 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."), 188 | mcp.WithString("path", 189 | mcp.Description("Starting path for the search (must be a directory)"), 190 | mcp.Required(), 191 | ), 192 | mcp.WithString("substring", 193 | mcp.Description("Text to search for within file contents"), 194 | mcp.Required(), 195 | ), 196 | mcp.WithNumber("depth", 197 | mcp.Description("Maximum directory depth to search (default: unlimited)"), 198 | ), 199 | mcp.WithNumber("max_results", 200 | mcp.Description("Maximum number of results to return (default: 1000)"), 201 | ), 202 | ), h.HandleSearchWithinFiles) 203 | 204 | return s, nil 205 | } 206 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/tree_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/mark3labs/mcp-go/mcp" 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | ) 15 | 16 | func TestHandleTree(t *testing.T) { 17 | // Setup a temporary directory for the test 18 | tmpDir := t.TempDir() 19 | 20 | // Create a handler with the temp dir as an allowed path 21 | allowedDirs := resolveAllowedDirs(t, tmpDir) 22 | fsHandler, err := NewFilesystemHandler(allowedDirs) 23 | require.NoError(t, err) 24 | 25 | ctx := context.Background() 26 | 27 | // Create test directory structure 28 | // /tmpDir/ 29 | // ├── file1.txt 30 | // ├── subdir1/ 31 | // │ ├── file2.txt 32 | // │ └── subdir2/ 33 | // │ └── file3.txt 34 | // └── emptydir/ 35 | 36 | file1Path := filepath.Join(tmpDir, "file1.txt") 37 | err = os.WriteFile(file1Path, []byte("content1"), 0644) 38 | require.NoError(t, err) 39 | 40 | subdir1Path := filepath.Join(tmpDir, "subdir1") 41 | err = os.Mkdir(subdir1Path, 0755) 42 | require.NoError(t, err) 43 | 44 | file2Path := filepath.Join(subdir1Path, "file2.txt") 45 | err = os.WriteFile(file2Path, []byte("content2"), 0644) 46 | require.NoError(t, err) 47 | 48 | subdir2Path := filepath.Join(subdir1Path, "subdir2") 49 | err = os.Mkdir(subdir2Path, 0755) 50 | require.NoError(t, err) 51 | 52 | file3Path := filepath.Join(subdir2Path, "file3.txt") 53 | err = os.WriteFile(file3Path, []byte("content3"), 0644) 54 | require.NoError(t, err) 55 | 56 | emptydirPath := filepath.Join(tmpDir, "emptydir") 57 | err = os.Mkdir(emptydirPath, 0755) 58 | require.NoError(t, err) 59 | 60 | t.Run("tree with default depth", func(t *testing.T) { 61 | req := mcp.CallToolRequest{ 62 | Params: mcp.CallToolParams{ 63 | Arguments: map[string]interface{}{ 64 | "path": tmpDir, 65 | }, 66 | }, 67 | } 68 | 69 | res, err := fsHandler.HandleTree(ctx, req) 70 | require.NoError(t, err) 71 | require.False(t, res.IsError) 72 | 73 | // Verify the response contains tree structure 74 | require.Len(t, res.Content, 2) 75 | textContent := res.Content[0].(mcp.TextContent) 76 | assert.Contains(t, textContent.Text, "Directory tree for") 77 | assert.Contains(t, textContent.Text, "max depth: 3") 78 | 79 | // Parse the JSON to verify structure 80 | lines := textContent.Text 81 | assert.Contains(t, lines, "file1.txt") 82 | assert.Contains(t, lines, "subdir1") 83 | assert.Contains(t, lines, "file2.txt") 84 | assert.Contains(t, lines, "subdir2") 85 | assert.Contains(t, lines, "file3.txt") 86 | assert.Contains(t, lines, "emptydir") 87 | 88 | // Verify embedded resource 89 | embeddedResource := res.Content[1].(mcp.EmbeddedResource) 90 | assert.Equal(t, "resource", embeddedResource.Type) 91 | assert.Equal(t, "application/json", embeddedResource.Resource.(mcp.TextResourceContents).MIMEType) 92 | }) 93 | 94 | t.Run("tree with custom depth", func(t *testing.T) { 95 | req := mcp.CallToolRequest{ 96 | Params: mcp.CallToolParams{ 97 | Arguments: map[string]interface{}{ 98 | "path": tmpDir, 99 | "depth": 2.0, // Only go 2 levels deep 100 | }, 101 | }, 102 | } 103 | 104 | res, err := fsHandler.HandleTree(ctx, req) 105 | require.NoError(t, err) 106 | require.False(t, res.IsError) 107 | 108 | textContent := res.Content[0].(mcp.TextContent) 109 | assert.Contains(t, textContent.Text, "max depth: 2") 110 | 111 | // Should include file1.txt, subdir1, file2.txt, subdir2, emptydir 112 | // but NOT file3.txt (which is at depth 3) 113 | assert.Contains(t, textContent.Text, "file1.txt") 114 | assert.Contains(t, textContent.Text, "subdir1") 115 | assert.Contains(t, textContent.Text, "file2.txt") 116 | assert.Contains(t, textContent.Text, "subdir2") 117 | assert.Contains(t, textContent.Text, "emptydir") 118 | // file3.txt should not be included at depth 2 119 | assert.NotContains(t, textContent.Text, "file3.txt") 120 | }) 121 | 122 | t.Run("tree with depth 1", func(t *testing.T) { 123 | req := mcp.CallToolRequest{ 124 | Params: mcp.CallToolParams{ 125 | Arguments: map[string]interface{}{ 126 | "path": tmpDir, 127 | "depth": 1.0, // Only show immediate children 128 | }, 129 | }, 130 | } 131 | 132 | res, err := fsHandler.HandleTree(ctx, req) 133 | require.NoError(t, err) 134 | require.False(t, res.IsError) 135 | 136 | textContent := res.Content[0].(mcp.TextContent) 137 | assert.Contains(t, textContent.Text, "max depth: 1") 138 | 139 | // Should only include immediate children 140 | assert.Contains(t, textContent.Text, "file1.txt") 141 | assert.Contains(t, textContent.Text, "subdir1") 142 | assert.Contains(t, textContent.Text, "emptydir") 143 | // Should not include nested files 144 | assert.NotContains(t, textContent.Text, "file2.txt") 145 | assert.NotContains(t, textContent.Text, "subdir2") 146 | assert.NotContains(t, textContent.Text, "file3.txt") 147 | }) 148 | 149 | t.Run("tree of empty directory", func(t *testing.T) { 150 | req := mcp.CallToolRequest{ 151 | Params: mcp.CallToolParams{ 152 | Arguments: map[string]interface{}{ 153 | "path": emptydirPath, 154 | }, 155 | }, 156 | } 157 | 158 | res, err := fsHandler.HandleTree(ctx, req) 159 | require.NoError(t, err) 160 | require.False(t, res.IsError) 161 | 162 | textContent := res.Content[0].(mcp.TextContent) 163 | assert.Contains(t, textContent.Text, "Directory tree for") 164 | 165 | // Parse JSON to verify it's a directory with no children 166 | jsonStart := textContent.Text[strings.Index(textContent.Text, "{"):] 167 | var tree FileNode 168 | err = json.Unmarshal([]byte(jsonStart), &tree) 169 | require.NoError(t, err) 170 | assert.Equal(t, "directory", tree.Type) 171 | assert.Equal(t, "emptydir", tree.Name) 172 | assert.Nil(t, tree.Children) 173 | }) 174 | 175 | t.Run("try to tree a file instead of directory", func(t *testing.T) { 176 | req := mcp.CallToolRequest{ 177 | Params: mcp.CallToolParams{ 178 | Arguments: map[string]interface{}{ 179 | "path": file1Path, 180 | }, 181 | }, 182 | } 183 | 184 | res, err := fsHandler.HandleTree(ctx, req) 185 | require.NoError(t, err) 186 | require.True(t, res.IsError) 187 | 188 | require.Len(t, res.Content, 1) 189 | textContent := res.Content[0].(mcp.TextContent) 190 | assert.Contains(t, textContent.Text, "not a directory") 191 | }) 192 | 193 | t.Run("try to tree non-existent directory", func(t *testing.T) { 194 | nonExistentPath := filepath.Join(tmpDir, "non_existent_directory") 195 | 196 | req := mcp.CallToolRequest{ 197 | Params: mcp.CallToolParams{ 198 | Arguments: map[string]interface{}{ 199 | "path": nonExistentPath, 200 | }, 201 | }, 202 | } 203 | 204 | res, err := fsHandler.HandleTree(ctx, req) 205 | require.NoError(t, err) 206 | require.True(t, res.IsError) 207 | }) 208 | 209 | t.Run("path is in a non-allowed directory", func(t *testing.T) { 210 | otherDir := t.TempDir() 211 | 212 | req := mcp.CallToolRequest{ 213 | Params: mcp.CallToolParams{ 214 | Arguments: map[string]interface{}{ 215 | "path": otherDir, 216 | }, 217 | }, 218 | } 219 | 220 | res, err := fsHandler.HandleTree(ctx, req) 221 | require.NoError(t, err) 222 | require.True(t, res.IsError) 223 | }) 224 | } 225 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/read_multiple_files_test.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/mark3labs/mcp-go/mcp" 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestHandleReadMultipleFiles(t *testing.T) { 16 | // Setup a temporary directory for the test 17 | tmpDir := t.TempDir() 18 | 19 | // Create a handler with the temp dir as an allowed path 20 | allowedDirs := resolveAllowedDirs(t, tmpDir) 21 | fsHandler, err := NewFilesystemHandler(allowedDirs) 22 | require.NoError(t, err) 23 | 24 | ctx := context.Background() 25 | 26 | // Create test files 27 | file1Path := filepath.Join(tmpDir, "file1.txt") 28 | file1Content := "This is the content of file 1" 29 | err = os.WriteFile(file1Path, []byte(file1Content), 0644) 30 | require.NoError(t, err) 31 | 32 | file2Path := filepath.Join(tmpDir, "file2.txt") 33 | file2Content := "This is the content of file 2" 34 | err = os.WriteFile(file2Path, []byte(file2Content), 0644) 35 | require.NoError(t, err) 36 | 37 | // Create a directory 38 | dirPath := filepath.Join(tmpDir, "test_directory") 39 | err = os.Mkdir(dirPath, 0755) 40 | require.NoError(t, err) 41 | 42 | t.Run("read multiple text files", func(t *testing.T) { 43 | req := mcp.CallToolRequest{ 44 | Params: mcp.CallToolParams{ 45 | Arguments: map[string]interface{}{ 46 | "paths": []string{file1Path, file2Path}, 47 | }, 48 | }, 49 | } 50 | 51 | res, err := fsHandler.HandleReadMultipleFiles(ctx, req) 52 | require.NoError(t, err) 53 | require.False(t, res.IsError) 54 | 55 | // Verify the response contains content from both files 56 | require.GreaterOrEqual(t, len(res.Content), 4) // At least 2 headers + 2 content blocks 57 | 58 | // Convert all content to strings for easier checking 59 | var contentTexts []string 60 | for _, content := range res.Content { 61 | if textContent, ok := content.(mcp.TextContent); ok { 62 | contentTexts = append(contentTexts, textContent.Text) 63 | } 64 | } 65 | 66 | allText := strings.Join(contentTexts, "\n") 67 | assert.Contains(t, allText, "--- File: "+file1Path+" ---") 68 | assert.Contains(t, allText, "--- File: "+file2Path+" ---") 69 | assert.Contains(t, allText, file1Content) 70 | assert.Contains(t, allText, file2Content) 71 | }) 72 | 73 | t.Run("read single file", func(t *testing.T) { 74 | req := mcp.CallToolRequest{ 75 | Params: mcp.CallToolParams{ 76 | Arguments: map[string]interface{}{ 77 | "paths": []string{file1Path}, 78 | }, 79 | }, 80 | } 81 | 82 | res, err := fsHandler.HandleReadMultipleFiles(ctx, req) 83 | require.NoError(t, err) 84 | require.False(t, res.IsError) 85 | 86 | // Verify the response contains content from the file 87 | require.GreaterOrEqual(t, len(res.Content), 2) // At least 1 header + 1 content block 88 | 89 | var contentTexts []string 90 | for _, content := range res.Content { 91 | if textContent, ok := content.(mcp.TextContent); ok { 92 | contentTexts = append(contentTexts, textContent.Text) 93 | } 94 | } 95 | 96 | allText := strings.Join(contentTexts, "\n") 97 | assert.Contains(t, allText, "--- File: "+file1Path+" ---") 98 | assert.Contains(t, allText, file1Content) 99 | }) 100 | 101 | t.Run("try to read a directory", func(t *testing.T) { 102 | req := mcp.CallToolRequest{ 103 | Params: mcp.CallToolParams{ 104 | Arguments: map[string]interface{}{ 105 | "paths": []string{dirPath}, 106 | }, 107 | }, 108 | } 109 | 110 | res, err := fsHandler.HandleReadMultipleFiles(ctx, req) 111 | require.NoError(t, err) 112 | require.False(t, res.IsError) 113 | 114 | // Should get a message about it being a directory 115 | require.Len(t, res.Content, 1) 116 | textContent := res.Content[0].(mcp.TextContent) 117 | assert.Contains(t, textContent.Text, "is a directory") 118 | assert.Contains(t, textContent.Text, "Use list_directory tool") 119 | }) 120 | 121 | t.Run("try to read non-existent file", func(t *testing.T) { 122 | nonExistentPath := filepath.Join(tmpDir, "non_existent.txt") 123 | 124 | req := mcp.CallToolRequest{ 125 | Params: mcp.CallToolParams{ 126 | Arguments: map[string]interface{}{ 127 | "paths": []string{nonExistentPath}, 128 | }, 129 | }, 130 | } 131 | 132 | res, err := fsHandler.HandleReadMultipleFiles(ctx, req) 133 | require.NoError(t, err) 134 | require.False(t, res.IsError) // The operation succeeds but individual files may have errors 135 | 136 | // Should get an error message about the file not existing 137 | require.Len(t, res.Content, 1) 138 | textContent := res.Content[0].(mcp.TextContent) 139 | assert.Contains(t, textContent.Text, "Error accessing") 140 | assert.Contains(t, textContent.Text, nonExistentPath) 141 | }) 142 | 143 | t.Run("mix of valid and invalid files", func(t *testing.T) { 144 | nonExistentPath := filepath.Join(tmpDir, "non_existent.txt") 145 | 146 | req := mcp.CallToolRequest{ 147 | Params: mcp.CallToolParams{ 148 | Arguments: map[string]interface{}{ 149 | "paths": []string{file1Path, nonExistentPath, file2Path}, 150 | }, 151 | }, 152 | } 153 | 154 | res, err := fsHandler.HandleReadMultipleFiles(ctx, req) 155 | require.NoError(t, err) 156 | require.False(t, res.IsError) 157 | 158 | // Should have content for valid files and error messages for invalid ones 159 | require.GreaterOrEqual(t, len(res.Content), 5) // At least 2 headers + 2 content blocks + 1 error 160 | 161 | var contentTexts []string 162 | for _, content := range res.Content { 163 | if textContent, ok := content.(mcp.TextContent); ok { 164 | contentTexts = append(contentTexts, textContent.Text) 165 | } 166 | } 167 | 168 | allText := strings.Join(contentTexts, "\n") 169 | assert.Contains(t, allText, "--- File: "+file1Path+" ---") 170 | assert.Contains(t, allText, "--- File: "+file2Path+" ---") 171 | assert.Contains(t, allText, file1Content) 172 | assert.Contains(t, allText, file2Content) 173 | assert.Contains(t, allText, "Error accessing") 174 | assert.Contains(t, allText, nonExistentPath) 175 | }) 176 | 177 | t.Run("no files specified", func(t *testing.T) { 178 | req := mcp.CallToolRequest{ 179 | Params: mcp.CallToolParams{ 180 | Arguments: map[string]interface{}{ 181 | "paths": []string{}, 182 | }, 183 | }, 184 | } 185 | 186 | res, err := fsHandler.HandleReadMultipleFiles(ctx, req) 187 | require.NoError(t, err) 188 | require.True(t, res.IsError) 189 | 190 | require.Len(t, res.Content, 1) 191 | textContent := res.Content[0].(mcp.TextContent) 192 | assert.Contains(t, textContent.Text, "No files specified to read") 193 | }) 194 | 195 | t.Run("too many files", func(t *testing.T) { 196 | // Create a slice with more than 50 files (the maximum) 197 | var manyPaths []string 198 | for i := 0; i < 51; i++ { 199 | manyPaths = append(manyPaths, filepath.Join(tmpDir, "file.txt")) 200 | } 201 | 202 | req := mcp.CallToolRequest{ 203 | Params: mcp.CallToolParams{ 204 | Arguments: map[string]interface{}{ 205 | "paths": manyPaths, 206 | }, 207 | }, 208 | } 209 | 210 | res, err := fsHandler.HandleReadMultipleFiles(ctx, req) 211 | require.NoError(t, err) 212 | require.True(t, res.IsError) 213 | 214 | require.Len(t, res.Content, 1) 215 | textContent := res.Content[0].(mcp.TextContent) 216 | assert.Contains(t, textContent.Text, "Too many files requested") 217 | assert.Contains(t, textContent.Text, "Maximum is 50") 218 | }) 219 | 220 | t.Run("path in non-allowed directory", func(t *testing.T) { 221 | otherDir := t.TempDir() 222 | otherFile := filepath.Join(otherDir, "other.txt") 223 | 224 | req := mcp.CallToolRequest{ 225 | Params: mcp.CallToolParams{ 226 | Arguments: map[string]interface{}{ 227 | "paths": []string{otherFile}, 228 | }, 229 | }, 230 | } 231 | 232 | res, err := fsHandler.HandleReadMultipleFiles(ctx, req) 233 | require.NoError(t, err) 234 | require.False(t, res.IsError) // The operation succeeds but individual files may have errors 235 | 236 | require.Len(t, res.Content, 1) 237 | textContent := res.Content[0].(mcp.TextContent) 238 | assert.Contains(t, textContent.Text, "Error with path") 239 | assert.Contains(t, textContent.Text, otherFile) 240 | }) 241 | } 242 | ``` -------------------------------------------------------------------------------- /filesystemserver/handler/search_within_files.go: -------------------------------------------------------------------------------- ```go 1 | package handler 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/mark3labs/mcp-go/mcp" 12 | ) 13 | 14 | func (fs *FilesystemHandler) HandleSearchWithinFiles( 15 | ctx context.Context, 16 | request mcp.CallToolRequest, 17 | ) (*mcp.CallToolResult, error) { 18 | // Extract and validate parameters 19 | path, err := request.RequireString("path") 20 | if err != nil { 21 | return nil, err 22 | } 23 | substring, err := request.RequireString("substring") 24 | if err != nil { 25 | return nil, err 26 | } 27 | if substring == "" { 28 | return &mcp.CallToolResult{ 29 | Content: []mcp.Content{ 30 | mcp.TextContent{ 31 | Type: "text", 32 | Text: "Error: substring cannot be empty", 33 | }, 34 | }, 35 | IsError: true, 36 | }, nil 37 | } 38 | 39 | // Extract optional depth parameter 40 | maxDepth := 0 // 0 means unlimited 41 | if depthArg, err := request.RequireFloat("depth"); err == nil { 42 | maxDepth = int(depthArg) 43 | if maxDepth < 0 { 44 | return &mcp.CallToolResult{ 45 | Content: []mcp.Content{ 46 | mcp.TextContent{ 47 | Type: "text", 48 | Text: "Error: depth cannot be negative", 49 | }, 50 | }, 51 | IsError: true, 52 | }, nil 53 | } 54 | } 55 | 56 | // Extract optional max_results parameter 57 | maxResults := MAX_SEARCH_RESULTS // default limit 58 | if maxResultsArg, err := request.RequireFloat("max_results"); err == nil { 59 | maxResults = int(maxResultsArg) 60 | if maxResults <= 0 { 61 | return &mcp.CallToolResult{ 62 | Content: []mcp.Content{ 63 | mcp.TextContent{ 64 | Type: "text", 65 | Text: "Error: max_results must be positive", 66 | }, 67 | }, 68 | IsError: true, 69 | }, nil 70 | } 71 | } 72 | 73 | // Handle empty or relative paths like "." or "./" by converting to absolute path 74 | if path == "." || path == "./" { 75 | // Get current working directory 76 | cwd, err := os.Getwd() 77 | if err != nil { 78 | return &mcp.CallToolResult{ 79 | Content: []mcp.Content{ 80 | mcp.TextContent{ 81 | Type: "text", 82 | Text: fmt.Sprintf("Error resolving current directory: %v", err), 83 | }, 84 | }, 85 | IsError: true, 86 | }, nil 87 | } 88 | path = cwd 89 | } 90 | 91 | validPath, err := fs.validatePath(path) 92 | if err != nil { 93 | return &mcp.CallToolResult{ 94 | Content: []mcp.Content{ 95 | mcp.TextContent{ 96 | Type: "text", 97 | Text: fmt.Sprintf("Error: %v", err), 98 | }, 99 | }, 100 | IsError: true, 101 | }, nil 102 | } 103 | 104 | // Check if the path is a directory 105 | info, err := os.Stat(validPath) 106 | if err != nil { 107 | return &mcp.CallToolResult{ 108 | Content: []mcp.Content{ 109 | mcp.TextContent{ 110 | Type: "text", 111 | Text: fmt.Sprintf("Error: %v", err), 112 | }, 113 | }, 114 | IsError: true, 115 | }, nil 116 | } 117 | 118 | if !info.IsDir() { 119 | return &mcp.CallToolResult{ 120 | Content: []mcp.Content{ 121 | mcp.TextContent{ 122 | Type: "text", 123 | Text: "Error: search path must be a directory", 124 | }, 125 | }, 126 | IsError: true, 127 | }, nil 128 | } 129 | 130 | // Perform the search 131 | results, err := searchWithinFiles(validPath, substring, maxDepth, maxResults, fs) 132 | if err != nil { 133 | return &mcp.CallToolResult{ 134 | Content: []mcp.Content{ 135 | mcp.TextContent{ 136 | Type: "text", 137 | Text: fmt.Sprintf("Error searching within files: %v", err), 138 | }, 139 | }, 140 | IsError: true, 141 | }, nil 142 | } 143 | 144 | if len(results) == 0 { 145 | return &mcp.CallToolResult{ 146 | Content: []mcp.Content{ 147 | mcp.TextContent{ 148 | Type: "text", 149 | Text: fmt.Sprintf("No occurrences of '%s' found in files under %s", substring, path), 150 | }, 151 | }, 152 | }, nil 153 | } 154 | 155 | // Format search results 156 | var formattedResults strings.Builder 157 | formattedResults.WriteString(fmt.Sprintf("Found %d occurrences of '%s':\n\n", len(results), substring)) 158 | 159 | // Group results by file for easier readability 160 | fileResultsMap := make(map[string][]SearchResult) 161 | for _, result := range results { 162 | fileResultsMap[result.FilePath] = append(fileResultsMap[result.FilePath], result) 163 | } 164 | 165 | // Display results grouped by file 166 | for filePath, fileResults := range fileResultsMap { 167 | resourceURI := pathToResourceURI(filePath) 168 | formattedResults.WriteString(fmt.Sprintf("File: %s (%s)\n", filePath, resourceURI)) 169 | 170 | for _, result := range fileResults { 171 | // Truncate line content if too long (keeping context around the match) 172 | lineContent := result.LineContent 173 | if len(lineContent) > 100 { 174 | // Find the substring position 175 | substrPos := strings.Index(strings.ToLower(lineContent), strings.ToLower(substring)) 176 | 177 | // Calculate start and end positions for context 178 | contextStart := max(0, substrPos-30) 179 | contextEnd := min(len(lineContent), substrPos+len(substring)+30) 180 | 181 | if contextStart > 0 { 182 | lineContent = "..." + lineContent[contextStart:contextEnd] 183 | } else { 184 | lineContent = lineContent[:contextEnd] 185 | } 186 | 187 | if contextEnd < len(result.LineContent) { 188 | lineContent += "..." 189 | } 190 | } 191 | 192 | formattedResults.WriteString(fmt.Sprintf(" Line %d: %s\n", result.LineNumber, lineContent)) 193 | } 194 | formattedResults.WriteString("\n") 195 | } 196 | 197 | // If results were limited, note this in the output 198 | if len(results) >= maxResults { 199 | formattedResults.WriteString(fmt.Sprintf("\nNote: Results limited to %d matches. There may be more occurrences.", maxResults)) 200 | } 201 | 202 | return &mcp.CallToolResult{ 203 | Content: []mcp.Content{ 204 | mcp.TextContent{ 205 | Type: "text", 206 | Text: formattedResults.String(), 207 | }, 208 | }, 209 | }, nil 210 | } 211 | 212 | // searchWithinFiles searches for a substring within file contents 213 | func searchWithinFiles( 214 | rootPath, substring string, maxDepth int, maxResults int, fs *FilesystemHandler, 215 | ) ([]SearchResult, error) { 216 | var results []SearchResult 217 | resultCount := 0 218 | currentDepth := 0 219 | 220 | // Walk the directory tree 221 | err := filepath.Walk( 222 | rootPath, 223 | func(path string, info os.FileInfo, err error) error { 224 | if err != nil { 225 | return nil // Skip errors and continue 226 | } 227 | 228 | // Check if we've reached the maximum number of results 229 | if resultCount >= maxResults { 230 | return filepath.SkipDir 231 | } 232 | 233 | // Try to validate path 234 | validPath, err := fs.validatePath(path) 235 | if err != nil { 236 | return nil // Skip invalid paths 237 | } 238 | 239 | // Skip directories, only search files 240 | if info.IsDir() { 241 | // Calculate depth for this directory 242 | relPath, err := filepath.Rel(rootPath, path) 243 | if err != nil { 244 | return nil // Skip on error 245 | } 246 | 247 | // Count separators to determine depth (empty or "." means we're at rootPath) 248 | if relPath == "" || relPath == "." { 249 | currentDepth = 0 250 | } else { 251 | currentDepth = strings.Count(relPath, string(filepath.Separator)) + 1 252 | } 253 | 254 | // Skip directories beyond max depth if specified 255 | if maxDepth > 0 && currentDepth >= maxDepth { 256 | return filepath.SkipDir 257 | } 258 | return nil 259 | } 260 | 261 | // Skip files that are too large 262 | if info.Size() > MAX_SEARCHABLE_SIZE { 263 | return nil 264 | } 265 | 266 | // Determine MIME type and skip non-text files 267 | mimeType := detectMimeType(validPath) 268 | if !isTextFile(mimeType) { 269 | return nil 270 | } 271 | 272 | // Open the file and search for the substring 273 | file, err := os.Open(validPath) 274 | if err != nil { 275 | return nil // Skip files that can't be opened 276 | } 277 | defer file.Close() 278 | 279 | // Create a scanner to read the file line by line 280 | scanner := bufio.NewScanner(file) 281 | lineNum := 0 282 | 283 | // Scan each line 284 | for scanner.Scan() { 285 | lineNum++ 286 | line := scanner.Text() 287 | 288 | // Check if the line contains the substring 289 | if strings.Contains(line, substring) { 290 | // Add to results 291 | results = append(results, SearchResult{ 292 | FilePath: validPath, 293 | LineNumber: lineNum, 294 | LineContent: line, 295 | ResourceURI: pathToResourceURI(validPath), 296 | }) 297 | resultCount++ 298 | 299 | // Check if we've reached the maximum results 300 | if resultCount >= maxResults { 301 | return filepath.SkipDir 302 | } 303 | } 304 | } 305 | 306 | // Check for scanner errors 307 | if err := scanner.Err(); err != nil { 308 | return nil // Skip files with scanning errors 309 | } 310 | 311 | return nil 312 | }, 313 | ) 314 | 315 | if err != nil { 316 | return nil, err 317 | } 318 | 319 | return results, nil 320 | } 321 | 322 | // Helper function since Go < 1.21 doesn't have min/max functions 323 | func min(a, b int) int { 324 | if a < b { 325 | return a 326 | } 327 | return b 328 | } 329 | 330 | // Helper function since Go < 1.21 doesn't have min/max functions 331 | func max(a, b int) int { 332 | if a > b { 333 | return a 334 | } 335 | return b 336 | } 337 | ```