diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4e609da --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,21 @@ +name: Test + +on: [push] + +jobs: + tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '>=1.21.0' + check-latest: true + + - name: Run tests + run: go test ./... diff --git a/filesystemserver/handler.go b/filesystemserver/handler.go new file mode 100644 index 0000000..937af64 --- /dev/null +++ b/filesystemserver/handler.go @@ -0,0 +1,1966 @@ +package filesystemserver + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gabriel-vasile/mimetype" + "github.com/mark3labs/mcp-go/mcp" + "slices" +) + +const ( + // Maximum size for inline content (5MB) + MAX_INLINE_SIZE = 5 * 1024 * 1024 + // Maximum size for base64 encoding (1MB) + MAX_BASE64_SIZE = 1 * 1024 * 1024 +) + +type FileInfo struct { + Size int64 `json:"size"` + Created time.Time `json:"created"` + Modified time.Time `json:"modified"` + Accessed time.Time `json:"accessed"` + IsDirectory bool `json:"isDirectory"` + IsFile bool `json:"isFile"` + Permissions string `json:"permissions"` +} + +// FileNode represents a node in the file tree +type FileNode struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // "file" or "directory" + Size int64 `json:"size,omitempty"` + Modified time.Time `json:"modified,omitempty"` + Children []*FileNode `json:"children,omitempty"` +} + +type FilesystemHandler struct { + allowedDirs []string +} + +func NewFilesystemHandler(allowedDirs []string) (*FilesystemHandler, error) { + // Normalize and validate directories + normalized := make([]string, 0, len(allowedDirs)) + for _, dir := range allowedDirs { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("failed to resolve path %s: %w", dir, err) + } + + info, err := os.Stat(abs) + if err != nil { + return nil, fmt.Errorf( + "failed to access directory %s: %w", + abs, + err, + ) + } + if !info.IsDir() { + return nil, fmt.Errorf("path is not a directory: %s", abs) + } + + // Ensure the path ends with a separator to prevent prefix matching issues + // For example, /tmp/foo should not match /tmp/foobar + normalized = append(normalized, filepath.Clean(abs)+string(filepath.Separator)) + } + return &FilesystemHandler{ + allowedDirs: normalized, + }, nil +} + +// isPathInAllowedDirs checks if a path is within any of the allowed directories +func (fs *FilesystemHandler) isPathInAllowedDirs(path string) bool { + // Ensure path is absolute and clean + absPath, err := filepath.Abs(path) + if err != nil { + return false + } + + // Add trailing separator to ensure we're checking a directory or a file within a directory + // and not a prefix match (e.g., /tmp/foo should not match /tmp/foobar) + if !strings.HasSuffix(absPath, string(filepath.Separator)) { + // If it's a file, we need to check its directory + if info, err := os.Stat(absPath); err == nil && !info.IsDir() { + absPath = filepath.Dir(absPath) + string(filepath.Separator) + } else { + absPath = absPath + string(filepath.Separator) + } + } + + // Check if the path is within any of the allowed directories + for _, dir := range fs.allowedDirs { + if strings.HasPrefix(absPath, dir) { + return true + } + } + return false +} + +// buildTree builds a tree representation of the filesystem starting at the given path +func (fs *FilesystemHandler) buildTree(path string, maxDepth int, currentDepth int, followSymlinks bool) (*FileNode, error) { + // Validate the path + validPath, err := fs.validatePath(path) + if err != nil { + return nil, err + } + + // Get file info + info, err := os.Stat(validPath) + if err != nil { + return nil, err + } + + // Create the node + node := &FileNode{ + Name: filepath.Base(validPath), + Path: validPath, + Modified: info.ModTime(), + } + + // Set type and size + if info.IsDir() { + node.Type = "directory" + + // If we haven't reached the max depth, process children + if currentDepth < maxDepth { + // Read directory entries + entries, err := os.ReadDir(validPath) + if err != nil { + return nil, err + } + + // Process each entry + for _, entry := range entries { + entryPath := filepath.Join(validPath, entry.Name()) + + // Handle symlinks + if entry.Type()&os.ModeSymlink != 0 { + if !followSymlinks { + // Skip symlinks if not following them + continue + } + + // Resolve symlink + linkDest, err := filepath.EvalSymlinks(entryPath) + if err != nil { + // Skip invalid symlinks + continue + } + + // Validate the symlink destination is within allowed directories + if !fs.isPathInAllowedDirs(linkDest) { + // Skip symlinks pointing outside allowed directories + continue + } + + entryPath = linkDest + } + + // Recursively build child node + childNode, err := fs.buildTree(entryPath, maxDepth, currentDepth+1, followSymlinks) + if err != nil { + // Skip entries with errors + continue + } + + // Add child to the current node + node.Children = append(node.Children, childNode) + } + } + } else { + node.Type = "file" + node.Size = info.Size() + } + + return node, nil +} + +func (fs *FilesystemHandler) validatePath(requestedPath string) (string, error) { + // Always convert to absolute path first + abs, err := filepath.Abs(requestedPath) + if err != nil { + return "", fmt.Errorf("invalid path: %w", err) + } + + // Check if path is within allowed directories + if !fs.isPathInAllowedDirs(abs) { + return "", fmt.Errorf( + "access denied - path outside allowed directories: %s", + abs, + ) + } + + // Handle symlinks + realPath, err := filepath.EvalSymlinks(abs) + if err != nil { + if !os.IsNotExist(err) { + return "", err + } + // For new files, check parent directory + parent := filepath.Dir(abs) + realParent, err := filepath.EvalSymlinks(parent) + if err != nil { + return "", fmt.Errorf("parent directory does not exist: %s", parent) + } + + if !fs.isPathInAllowedDirs(realParent) { + return "", fmt.Errorf( + "access denied - parent directory outside allowed directories", + ) + } + return abs, nil + } + + // Check if the real path (after resolving symlinks) is still within allowed directories + if !fs.isPathInAllowedDirs(realPath) { + return "", fmt.Errorf( + "access denied - symlink target outside allowed directories", + ) + } + + return realPath, nil +} + +func (fs *FilesystemHandler) getFileStats(path string) (FileInfo, error) { + info, err := os.Stat(path) + if err != nil { + return FileInfo{}, err + } + + return FileInfo{ + Size: info.Size(), + Created: info.ModTime(), // Note: ModTime used as birth time isn't always available + Modified: info.ModTime(), + Accessed: info.ModTime(), // Note: Access time isn't always available + IsDirectory: info.IsDir(), + IsFile: !info.IsDir(), + Permissions: fmt.Sprintf("%o", info.Mode().Perm()), + }, nil +} + +func (fs *FilesystemHandler) searchFiles( + rootPath, pattern string, +) ([]string, error) { + var results []string + pattern = strings.ToLower(pattern) + + err := filepath.Walk( + rootPath, + func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil // Skip errors and continue + } + + // Try to validate path + if _, err := fs.validatePath(path); err != nil { + return nil // Skip invalid paths + } + + if strings.Contains(strings.ToLower(info.Name()), pattern) { + results = append(results, path) + } + return nil + }, + ) + if err != nil { + return nil, err + } + return results, nil +} + +// detectMimeType tries to determine the MIME type of a file +func detectMimeType(path string) string { + // Use mimetype library for more accurate detection + mtype, err := mimetype.DetectFile(path) + if err != nil { + // Fallback to extension-based detection if file can't be read + ext := filepath.Ext(path) + if ext != "" { + mimeType := mime.TypeByExtension(ext) + if mimeType != "" { + return mimeType + } + } + return "application/octet-stream" // Default + } + + return mtype.String() +} + +// isTextFile determines if a file is likely a text file based on MIME type +func isTextFile(mimeType string) bool { + // Check for common text MIME types + if strings.HasPrefix(mimeType, "text/") { + return true + } + + // Common application types that are text-based + textApplicationTypes := []string{ + "application/json", + "application/xml", + "application/javascript", + "application/x-javascript", + "application/typescript", + "application/x-typescript", + "application/x-yaml", + "application/yaml", + "application/toml", + "application/x-sh", + "application/x-shellscript", + } + + if slices.Contains(textApplicationTypes, mimeType) { + return true + } + + // Check for +format types + if strings.Contains(mimeType, "+xml") || + strings.Contains(mimeType, "+json") || + strings.Contains(mimeType, "+yaml") { + return true + } + + // Common code file types that might be misidentified + if strings.HasPrefix(mimeType, "text/x-") { + return true + } + + if strings.HasPrefix(mimeType, "application/x-") && + (strings.Contains(mimeType, "script") || + strings.Contains(mimeType, "source") || + strings.Contains(mimeType, "code")) { + return true + } + + return false +} + +// isImageFile determines if a file is an image based on MIME type +func isImageFile(mimeType string) bool { + return strings.HasPrefix(mimeType, "image/") || + (mimeType == "application/xml" && strings.HasSuffix(strings.ToLower(mimeType), ".svg")) +} + +// pathToResourceURI converts a file path to a resource URI +func pathToResourceURI(path string) string { + return "file://" + path +} + +// Resource handler +func (fs *FilesystemHandler) handleReadResource( + ctx context.Context, + request mcp.ReadResourceRequest, +) ([]mcp.ResourceContents, error) { + uri := request.Params.URI + + // Check if it's a file:// URI + if !strings.HasPrefix(uri, "file://") { + return nil, fmt.Errorf("unsupported URI scheme: %s", uri) + } + + // Extract the path from the URI + path := strings.TrimPrefix(uri, "file://") + + // Validate the path + validPath, err := fs.validatePath(path) + if err != nil { + return nil, err + } + + // Get file info + fileInfo, err := os.Stat(validPath) + if err != nil { + return nil, err + } + + // If it's a directory, return a listing + if fileInfo.IsDir() { + entries, err := os.ReadDir(validPath) + if err != nil { + return nil, err + } + + var result strings.Builder + result.WriteString(fmt.Sprintf("Directory listing for: %s\n\n", validPath)) + + for _, entry := range entries { + entryPath := filepath.Join(validPath, entry.Name()) + entryURI := pathToResourceURI(entryPath) + + if entry.IsDir() { + result.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", entry.Name(), entryURI)) + } else { + info, err := entry.Info() + if err == nil { + result.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", + entry.Name(), entryURI, info.Size())) + } else { + result.WriteString(fmt.Sprintf("[FILE] %s (%s)\n", entry.Name(), entryURI)) + } + } + } + + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: uri, + MIMEType: "text/plain", + Text: result.String(), + }, + }, nil + } + + // It's a file, determine how to handle it + mimeType := detectMimeType(validPath) + + // Check file size + if fileInfo.Size() > MAX_INLINE_SIZE { + // File is too large to inline, return a reference instead + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: uri, + MIMEType: "text/plain", + Text: fmt.Sprintf("File is too large to display inline (%d bytes). Use the read_file tool to access specific portions.", fileInfo.Size()), + }, + }, nil + } + + // Read the file content + content, err := os.ReadFile(validPath) + if err != nil { + return nil, err + } + + // Handle based on content type + if isTextFile(mimeType) { + // It's a text file, return as text + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: uri, + MIMEType: mimeType, + Text: string(content), + }, + }, nil + } else { + // It's a binary file + if fileInfo.Size() <= MAX_BASE64_SIZE { + // Small enough for base64 encoding + return []mcp.ResourceContents{ + mcp.BlobResourceContents{ + URI: uri, + MIMEType: mimeType, + Blob: base64.StdEncoding.EncodeToString(content), + }, + }, nil + } else { + // Too large for base64, return a reference + return []mcp.ResourceContents{ + mcp.TextResourceContents{ + URI: uri, + MIMEType: "text/plain", + Text: fmt.Sprintf("Binary file (%s, %d bytes). Use the read_file tool to access specific portions.", mimeType, fileInfo.Size()), + }, + }, nil + } + } +} + +// Tool handlers + +func (fs *FilesystemHandler) handleReadFile( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + path, ok := request.Params.Arguments["path"].(string) + if !ok { + return nil, fmt.Errorf("path must be a string") + } + + // Handle empty or relative paths like "." or "./" by converting to absolute path + if path == "." || path == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + path = cwd + } + + validPath, err := fs.validatePath(path) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if it's a directory + info, err := os.Stat(validPath) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + if info.IsDir() { + // For directories, return a resource reference instead + resourceURI := pathToResourceURI(validPath) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("This is a directory. Use the resource URI to browse its contents: %s", resourceURI), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Directory: %s", validPath), + }, + }, + }, + }, nil + } + + // Determine MIME type + mimeType := detectMimeType(validPath) + + // Check file size + if info.Size() > MAX_INLINE_SIZE { + // File is too large to inline, return a resource reference + resourceURI := pathToResourceURI(validPath) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("File is too large to display inline (%d bytes). Access it via resource URI: %s", info.Size(), resourceURI), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Large file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), + }, + }, + }, + }, nil + } + + // Read file content + content, err := os.ReadFile(validPath) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error reading file: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if it's a text file + if isTextFile(mimeType) { + // It's a text file, return as text + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: string(content), + }, + }, + }, nil + } else if isImageFile(mimeType) { + // It's an image file, return as image content + if info.Size() <= MAX_BASE64_SIZE { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Image file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), + }, + mcp.ImageContent{ + Type: "image", + Data: base64.StdEncoding.EncodeToString(content), + MIMEType: mimeType, + }, + }, + }, nil + } else { + // Too large for base64, return a reference + resourceURI := pathToResourceURI(validPath) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Image file is too large to display inline (%d bytes). Access it via resource URI: %s", info.Size(), resourceURI), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Large image: %s (%s, %d bytes)", validPath, mimeType, info.Size()), + }, + }, + }, + }, nil + } + } else { + // It's another type of binary file + resourceURI := pathToResourceURI(validPath) + + if info.Size() <= MAX_BASE64_SIZE { + // Small enough for base64 encoding + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.BlobResourceContents{ + URI: resourceURI, + MIMEType: mimeType, + Blob: base64.StdEncoding.EncodeToString(content), + }, + }, + }, + }, nil + } else { + // Too large for base64, return a reference + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Binary file: %s (%s, %d bytes). Access it via resource URI: %s", validPath, mimeType, info.Size(), resourceURI), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), + }, + }, + }, + }, nil + } + } +} + +func (fs *FilesystemHandler) handleWriteFile( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + path, ok := request.Params.Arguments["path"].(string) + if !ok { + return nil, fmt.Errorf("path must be a string") + } + content, ok := request.Params.Arguments["content"].(string) + if !ok { + return nil, fmt.Errorf("content must be a string") + } + + // Handle empty or relative paths like "." or "./" by converting to absolute path + if path == "." || path == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + path = cwd + } + + validPath, err := fs.validatePath(path) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if it's a directory + if info, err := os.Stat(validPath); err == nil && info.IsDir() { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: Cannot write to a directory", + }, + }, + IsError: true, + }, nil + } + + // Create parent directories if they don't exist + parentDir := filepath.Dir(validPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error creating parent directories: %v", err), + }, + }, + IsError: true, + }, nil + } + + if err := os.WriteFile(validPath, []byte(content), 0644); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error writing file: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Get file info for the response + info, err := os.Stat(validPath) + if err != nil { + // File was written but we couldn't get info + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Successfully wrote to %s", path), + }, + }, + }, nil + } + + resourceURI := pathToResourceURI(validPath) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Successfully wrote %d bytes to %s", info.Size(), path), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("File: %s (%d bytes)", validPath, info.Size()), + }, + }, + }, + }, nil +} + +func (fs *FilesystemHandler) handleListDirectory( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + path, ok := request.Params.Arguments["path"].(string) + if !ok { + return nil, fmt.Errorf("path must be a string") + } + + // Handle empty or relative paths like "." or "./" by converting to absolute path + if path == "." || path == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + path = cwd + } + + validPath, err := fs.validatePath(path) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if it's a directory + info, err := os.Stat(validPath) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + if !info.IsDir() { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: Path is not a directory", + }, + }, + IsError: true, + }, nil + } + + entries, err := os.ReadDir(validPath) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error reading directory: %v", err), + }, + }, + IsError: true, + }, nil + } + + var result strings.Builder + result.WriteString(fmt.Sprintf("Directory listing for: %s\n\n", validPath)) + + for _, entry := range entries { + entryPath := filepath.Join(validPath, entry.Name()) + resourceURI := pathToResourceURI(entryPath) + + if entry.IsDir() { + result.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", entry.Name(), resourceURI)) + } else { + info, err := entry.Info() + if err == nil { + result.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", + entry.Name(), resourceURI, info.Size())) + } else { + result.WriteString(fmt.Sprintf("[FILE] %s (%s)\n", entry.Name(), resourceURI)) + } + } + } + + // Return both text content and embedded resource + resourceURI := pathToResourceURI(validPath) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: result.String(), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Directory: %s", validPath), + }, + }, + }, + }, nil +} + +func (fs *FilesystemHandler) handleCreateDirectory( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + path, ok := request.Params.Arguments["path"].(string) + if !ok { + return nil, fmt.Errorf("path must be a string") + } + + // Handle empty or relative paths like "." or "./" by converting to absolute path + if path == "." || path == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + path = cwd + } + + validPath, err := fs.validatePath(path) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if path already exists + if info, err := os.Stat(validPath); err == nil { + if info.IsDir() { + resourceURI := pathToResourceURI(validPath) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Directory already exists: %s", path), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Directory: %s", validPath), + }, + }, + }, + }, nil + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: Path exists but is not a directory: %s", path), + }, + }, + IsError: true, + }, nil + } + + if err := os.MkdirAll(validPath, 0755); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error creating directory: %v", err), + }, + }, + IsError: true, + }, nil + } + + resourceURI := pathToResourceURI(validPath) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Successfully created directory %s", path), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Directory: %s", validPath), + }, + }, + }, + }, nil +} + +func (fs *FilesystemHandler) handleCopyFile( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + source, ok := request.Params.Arguments["source"].(string) + if !ok { + return nil, fmt.Errorf("source must be a string") + } + destination, ok := request.Params.Arguments["destination"].(string) + if !ok { + return nil, fmt.Errorf("destination must be a string") + } + + // Handle empty or relative paths for source + if source == "." || source == "./" { + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + source = cwd + } + if destination == "." || destination == "./" { + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + destination = cwd + } + + validSource, err := fs.validatePath(source) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error with source path: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if source exists + srcInfo, err := os.Stat(validSource) + if os.IsNotExist(err) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: Source does not exist: %s", source), + }, + }, + IsError: true, + }, nil + } else if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error accessing source: %v", err), + }, + }, + IsError: true, + }, nil + } + + validDest, err := fs.validatePath(destination) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error with destination path: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Create parent directory for destination if it doesn't exist + destDir := filepath.Dir(validDest) + if err := os.MkdirAll(destDir, 0755); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error creating destination directory: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Perform the copy operation based on whether source is a file or directory + if srcInfo.IsDir() { + // It's a directory, copy recursively + if err := copyDir(validSource, validDest); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error copying directory: %v", err), + }, + }, + IsError: true, + }, nil + } + } else { + // It's a file, copy directly + if err := copyFile(validSource, validDest); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error copying file: %v", err), + }, + }, + IsError: true, + }, nil + } + } + + resourceURI := pathToResourceURI(validDest) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf( + "Successfully copied %s to %s", + source, + destination, + ), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Copied file: %s", validDest), + }, + }, + }, + }, nil +} + +// copyFile copies a single file from src to dst +func copyFile(src, dst string) error { + // Open the source file + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + // Create the destination file + destFile, err := os.Create(dst) + if err != nil { + return err + } + defer destFile.Close() + + // Copy the contents + if _, err := io.Copy(destFile, sourceFile); err != nil { + return err + } + + // Get source file mode + sourceInfo, err := os.Stat(src) + if err != nil { + return err + } + + // Set the same file mode on destination + return os.Chmod(dst, sourceInfo.Mode()) +} + +// copyDir recursively copies a directory tree from src to dst +func copyDir(src, dst string) error { + // Get properties of source dir + srcInfo, err := os.Stat(src) + if err != nil { + return err + } + + // Create the destination directory with the same permissions + if err = os.MkdirAll(dst, srcInfo.Mode()); err != nil { + return err + } + + // Read directory entries + entries, err := os.ReadDir(src) + if err != nil { + return err + } + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + // Handle symlinks + if entry.Type()&os.ModeSymlink != 0 { + // For simplicity, we'll skip symlinks in this implementation + continue + } + + // Recursively copy subdirectories or copy files + if entry.IsDir() { + if err = copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err = copyFile(srcPath, dstPath); err != nil { + return err + } + } + } + + return nil +} + +func (fs *FilesystemHandler) handleMoveFile( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + source, ok := request.Params.Arguments["source"].(string) + if !ok { + return nil, fmt.Errorf("source must be a string") + } + destination, ok := request.Params.Arguments["destination"].(string) + if !ok { + return nil, fmt.Errorf("destination must be a string") + } + + // Handle empty or relative paths for source + if source == "." || source == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + source = cwd + } + + // Handle empty or relative paths for destination + if destination == "." || destination == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + destination = cwd + } + + validSource, err := fs.validatePath(source) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error with source path: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if source exists + if _, err := os.Stat(validSource); os.IsNotExist(err) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: Source does not exist: %s", source), + }, + }, + IsError: true, + }, nil + } + + validDest, err := fs.validatePath(destination) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error with destination path: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Create parent directory for destination if it doesn't exist + destDir := filepath.Dir(validDest) + if err := os.MkdirAll(destDir, 0755); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error creating destination directory: %v", err), + }, + }, + IsError: true, + }, nil + } + + if err := os.Rename(validSource, validDest); err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error moving file: %v", err), + }, + }, + IsError: true, + }, nil + } + + resourceURI := pathToResourceURI(validDest) + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf( + "Successfully moved %s to %s", + source, + destination, + ), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("Moved file: %s", validDest), + }, + }, + }, + }, nil +} + +func (fs *FilesystemHandler) handleSearchFiles( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + path, ok := request.Params.Arguments["path"].(string) + if !ok { + return nil, fmt.Errorf("path must be a string") + } + pattern, ok := request.Params.Arguments["pattern"].(string) + if !ok { + return nil, fmt.Errorf("pattern must be a string") + } + + // Handle empty or relative paths like "." or "./" by converting to absolute path + if path == "." || path == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + path = cwd + } + + validPath, err := fs.validatePath(path) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if it's a directory + info, err := os.Stat(validPath) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + if !info.IsDir() { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: Search path must be a directory", + }, + }, + IsError: true, + }, nil + } + + results, err := fs.searchFiles(validPath, pattern) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error searching files: %v", + err), + }, + }, + IsError: true, + }, nil + } + + if len(results) == 0 { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("No files found matching pattern '%s' in %s", pattern, path), + }, + }, + }, nil + } + + // Format results with resource URIs + var formattedResults strings.Builder + formattedResults.WriteString(fmt.Sprintf("Found %d results:\n\n", len(results))) + + for _, result := range results { + resourceURI := pathToResourceURI(result) + info, err := os.Stat(result) + if err == nil { + if info.IsDir() { + formattedResults.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", result, resourceURI)) + } else { + formattedResults.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", + result, resourceURI, info.Size())) + } + } else { + formattedResults.WriteString(fmt.Sprintf("%s (%s)\n", result, resourceURI)) + } + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: formattedResults.String(), + }, + }, + }, nil +} + +func (fs *FilesystemHandler) handleTree( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + path, ok := request.Params.Arguments["path"].(string) + if !ok { + return nil, fmt.Errorf("path must be a string") + } + + // Handle empty or relative paths like "." or "./" by converting to absolute path + if path == "." || path == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + path = cwd + } + + // Extract depth parameter (optional, default: 3) + depth := 3 // Default value + if depthParam, ok := request.Params.Arguments["depth"]; ok { + if d, ok := depthParam.(float64); ok { + depth = int(d) + } + } + + // Extract follow_symlinks parameter (optional, default: false) + followSymlinks := false // Default value + if followParam, ok := request.Params.Arguments["follow_symlinks"]; ok { + if f, ok := followParam.(bool); ok { + followSymlinks = f + } + } + + // Validate the path is within allowed directories + validPath, err := fs.validatePath(path) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Check if it's a directory + info, err := os.Stat(validPath) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + if !info.IsDir() { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "Error: The specified path is not a directory", + }, + }, + IsError: true, + }, nil + } + + // Build the tree structure + tree, err := fs.buildTree(validPath, depth, 0, followSymlinks) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error building directory tree: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Convert to JSON + jsonData, err := json.MarshalIndent(tree, "", " ") + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error generating JSON: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Create resource URI for the directory + resourceURI := pathToResourceURI(validPath) + + // Return the result + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Directory tree for %s (max depth: %d):\n\n%s", validPath, depth, string(jsonData)), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "application/json", + Text: string(jsonData), + }, + }, + }, + }, nil +} + +func (fs *FilesystemHandler) handleGetFileInfo( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + path, ok := request.Params.Arguments["path"].(string) + if !ok { + return nil, fmt.Errorf("path must be a string") + } + + // Handle empty or relative paths like "." or "./" by converting to absolute path + if path == "." || path == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory: %v", err), + }, + }, + IsError: true, + }, nil + } + path = cwd + } + + validPath, err := fs.validatePath(path) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error: %v", err), + }, + }, + IsError: true, + }, nil + } + + info, err := fs.getFileStats(validPath) + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error getting file info: %v", err), + }, + }, + IsError: true, + }, nil + } + + // Get MIME type for files + mimeType := "directory" + if info.IsFile { + mimeType = detectMimeType(validPath) + } + + resourceURI := pathToResourceURI(validPath) + + // Determine file type text + var fileTypeText string + if info.IsDirectory { + fileTypeText = "Directory" + } else { + fileTypeText = "File" + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf( + "File information for: %s\n\nSize: %d bytes\nCreated: %s\nModified: %s\nAccessed: %s\nIsDirectory: %v\nIsFile: %v\nPermissions: %s\nMIME Type: %s\nResource URI: %s", + validPath, + info.Size, + info.Created.Format(time.RFC3339), + info.Modified.Format(time.RFC3339), + info.Accessed.Format(time.RFC3339), + info.IsDirectory, + info.IsFile, + info.Permissions, + mimeType, + resourceURI, + ), + }, + mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.TextResourceContents{ + URI: resourceURI, + MIMEType: "text/plain", + Text: fmt.Sprintf("%s: %s (%s, %d bytes)", + fileTypeText, + validPath, + mimeType, + info.Size), + }, + }, + }, + }, nil +} + +func (fs *FilesystemHandler) handleReadMultipleFiles( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + pathsParam, ok := request.Params.Arguments["paths"] + if !ok { + return nil, fmt.Errorf("paths parameter is required") + } + + // Convert the paths parameter to a string slice + pathsSlice, ok := pathsParam.([]any) + if !ok { + return nil, fmt.Errorf("paths must be an array of strings") + } + + if len(pathsSlice) == 0 { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: "No files specified to read", + }, + }, + IsError: true, + }, nil + } + + // Maximum number of files to read in a single request + const maxFiles = 50 + if len(pathsSlice) > maxFiles { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Too many files requested. Maximum is %d files per request.", maxFiles), + }, + }, + IsError: true, + }, nil + } + + // Process each file + var results []mcp.Content + for _, pathInterface := range pathsSlice { + path, ok := pathInterface.(string) + if !ok { + return nil, fmt.Errorf("each path must be a string") + } + + // Handle empty or relative paths like "." or "./" by converting to absolute path + if path == "." || path == "./" { + // Get current working directory + cwd, err := os.Getwd() + if err != nil { + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error resolving current directory for path '%s': %v", path, err), + }) + continue + } + path = cwd + } + + validPath, err := fs.validatePath(path) + if err != nil { + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error with path '%s': %v", path, err), + }) + continue + } + + // Check if it's a directory + info, err := os.Stat(validPath) + if err != nil { + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error accessing '%s': %v", path, err), + }) + continue + } + + if info.IsDir() { + // For directories, return a resource reference instead + resourceURI := pathToResourceURI(validPath) + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("'%s' is a directory. Use list_directory tool or resource URI: %s", path, resourceURI), + }) + continue + } + + // Determine MIME type + mimeType := detectMimeType(validPath) + + // Check file size + if info.Size() > MAX_INLINE_SIZE { + // File is too large to inline, return a resource reference + resourceURI := pathToResourceURI(validPath) + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("File '%s' is too large to display inline (%d bytes). Access it via resource URI: %s", + path, info.Size(), resourceURI), + }) + continue + } + + // Read file content + content, err := os.ReadFile(validPath) + if err != nil { + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Error reading file '%s': %v", path, err), + }) + continue + } + + // Add file header + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("--- File: %s ---", path), + }) + + // Check if it's a text file + if isTextFile(mimeType) { + // It's a text file, return as text + results = append(results, mcp.TextContent{ + Type: "text", + Text: string(content), + }) + } else if isImageFile(mimeType) { + // It's an image file, return as image content + if info.Size() <= MAX_BASE64_SIZE { + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Image file: %s (%s, %d bytes)", path, mimeType, info.Size()), + }) + results = append(results, mcp.ImageContent{ + Type: "image", + Data: base64.StdEncoding.EncodeToString(content), + MIMEType: mimeType, + }) + } else { + // Too large for base64, return a reference + resourceURI := pathToResourceURI(validPath) + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Image file '%s' is too large to display inline (%d bytes). Access it via resource URI: %s", + path, info.Size(), resourceURI), + }) + } + } else { + // It's another type of binary file + resourceURI := pathToResourceURI(validPath) + + if info.Size() <= MAX_BASE64_SIZE { + // Small enough for base64 encoding + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", path, mimeType, info.Size()), + }) + results = append(results, mcp.EmbeddedResource{ + Type: "resource", + Resource: mcp.BlobResourceContents{ + URI: resourceURI, + MIMEType: mimeType, + Blob: base64.StdEncoding.EncodeToString(content), + }, + }) + } else { + // Too large for base64, return a reference + results = append(results, mcp.TextContent{ + Type: "text", + Text: fmt.Sprintf("Binary file '%s' (%s, %d bytes). Access it via resource URI: %s", + path, mimeType, info.Size(), resourceURI), + }) + } + } + } + + return &mcp.CallToolResult{ + Content: results, + }, nil +} + +func (fs *FilesystemHandler) handleListAllowedDirectories( + ctx context.Context, + request mcp.CallToolRequest, +) (*mcp.CallToolResult, error) { + // Remove the trailing separator for display purposes + displayDirs := make([]string, len(fs.allowedDirs)) + for i, dir := range fs.allowedDirs { + displayDirs[i] = strings.TrimSuffix(dir, string(filepath.Separator)) + } + + var result strings.Builder + result.WriteString("Allowed directories:\n\n") + + for _, dir := range displayDirs { + resourceURI := pathToResourceURI(dir) + result.WriteString(fmt.Sprintf("%s (%s)\n", dir, resourceURI)) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{ + mcp.TextContent{ + Type: "text", + Text: result.String(), + }, + }, + }, nil +} diff --git a/filesystemserver/handler_test.go b/filesystemserver/handler_test.go new file mode 100644 index 0000000..6ffd3c3 --- /dev/null +++ b/filesystemserver/handler_test.go @@ -0,0 +1,70 @@ +package filesystemserver + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadfile_Valid(t *testing.T) { + // prepare temp directory + dir := t.TempDir() + content := "test-content" + err := os.WriteFile(filepath.Join(dir, "test"), []byte(content), 0644) + require.NoError(t, err) + + handler, err := NewFilesystemHandler([]string{dir}) + require.NoError(t, err) + request := mcp.CallToolRequest{} + request.Params.Name = "read_file" + request.Params.Arguments = map[string]any{ + "path": filepath.Join(dir, "test"), + } + + result, err := handler.handleReadFile(context.Background(), request) + require.NoError(t, err) + assert.Len(t, result.Content, 1) + assert.Equal(t, content, result.Content[0].(mcp.TextContent).Text) +} + +func TestReadfile_Invalid(t *testing.T) { + dir := t.TempDir() + handler, err := NewFilesystemHandler([]string{dir}) + require.NoError(t, err) + + request := mcp.CallToolRequest{} + request.Params.Name = "read_file" + request.Params.Arguments = map[string]any{ + "path": filepath.Join(dir, "test"), + } + + result, err := handler.handleReadFile(context.Background(), request) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, fmt.Sprint(result.Content[0]), "no such file or directory") +} + +func TestReadfile_NoAccess(t *testing.T) { + dir1 := t.TempDir() + dir2 := t.TempDir() + + handler, err := NewFilesystemHandler([]string{dir1}) + require.NoError(t, err) + + request := mcp.CallToolRequest{} + request.Params.Name = "read_file" + request.Params.Arguments = map[string]any{ + "path": filepath.Join(dir2, "test"), + } + + result, err := handler.handleReadFile(context.Background(), request) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Contains(t, fmt.Sprint(result.Content[0]), "access denied - path outside allowed directories") +} diff --git a/filesystemserver/inprocess_test.go b/filesystemserver/inprocess_test.go new file mode 100644 index 0000000..efc81fc --- /dev/null +++ b/filesystemserver/inprocess_test.go @@ -0,0 +1,51 @@ +package filesystemserver_test + +import ( + "context" + "testing" + + "github.com/mark3labs/mcp-filesystem-server/filesystemserver" + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInProcess(t *testing.T) { + fss, err := filesystemserver.NewFilesystemServer([]string{"."}) + require.NoError(t, err) + + mcpClient, err := client.NewInProcessClient(fss) + require.NoError(t, err) + + err = mcpClient.Start(context.Background()) + require.NoError(t, err) + + // Initialize the client + initRequest := mcp.InitializeRequest{} + initRequest.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION + initRequest.Params.ClientInfo = mcp.Implementation{ + Name: "test-client", + Version: "1.0.0", + } + result, err := mcpClient.Initialize(context.Background(), initRequest) + require.NoError(t, err) + assert.Equal(t, "secure-filesystem-server", result.ServerInfo.Name) + assert.Equal(t, filesystemserver.Version, result.ServerInfo.Version) + + // just check for a specific tool + toolListResult, err := mcpClient.ListTools(context.Background(), mcp.ListToolsRequest{}) + require.NoError(t, err) + assert.NotEmpty(t, toolListResult.Tools) + found := false + for _, tool := range toolListResult.Tools { + if tool.Name == "read_file" { + found = true + break + } + } + assert.True(t, found, "read_file tool not found in the list of tools") + + err = mcpClient.Close() + require.NoError(t, err) +} diff --git a/filesystemserver/server.go b/filesystemserver/server.go index ca749de..ab106b0 100644 --- a/filesystemserver/server.go +++ b/filesystemserver/server.go @@ -1,107 +1,43 @@ package filesystemserver import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "mime" - "os" - "path/filepath" - "strings" - "time" - - "github.com/gabriel-vasile/mimetype" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) -const ( - // Maximum size for inline content (5MB) - MAX_INLINE_SIZE = 5 * 1024 * 1024 - // Maximum size for base64 encoding (1MB) - MAX_BASE64_SIZE = 1 * 1024 * 1024 -) - -type FileInfo struct { - Size int64 `json:"size"` - Created time.Time `json:"created"` - Modified time.Time `json:"modified"` - Accessed time.Time `json:"accessed"` - IsDirectory bool `json:"isDirectory"` - IsFile bool `json:"isFile"` - Permissions string `json:"permissions"` -} - -// FileNode represents a node in the file tree -type FileNode struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` // "file" or "directory" - Size int64 `json:"size,omitempty"` - Modified time.Time `json:"modified,omitempty"` - Children []*FileNode `json:"children,omitempty"` -} - -type FilesystemServer struct { - allowedDirs []string - server *server.MCPServer -} - -func NewFilesystemServer(allowedDirs []string) (*FilesystemServer, error) { - // Normalize and validate directories - normalized := make([]string, 0, len(allowedDirs)) - for _, dir := range allowedDirs { - abs, err := filepath.Abs(dir) - if err != nil { - return nil, fmt.Errorf("failed to resolve path %s: %w", dir, err) - } +var Version = "0.4.1" - info, err := os.Stat(abs) - if err != nil { - return nil, fmt.Errorf( - "failed to access directory %s: %w", - abs, - err, - ) - } - if !info.IsDir() { - return nil, fmt.Errorf("path is not a directory: %s", abs) - } +func NewFilesystemServer(allowedDirs []string) (*server.MCPServer, error) { - // Ensure the path ends with a separator to prevent prefix matching issues - // For example, /tmp/foo should not match /tmp/foobar - normalized = append(normalized, filepath.Clean(abs)+string(filepath.Separator)) + h, err := NewFilesystemHandler(allowedDirs) + if err != nil { + return nil, err } - s := &FilesystemServer{ - allowedDirs: normalized, - server: server.NewMCPServer( - "secure-filesystem-server", - "0.4.1", - server.WithResourceCapabilities(true, true), - ), - } + s := server.NewMCPServer( + "secure-filesystem-server", + Version, + server.WithResourceCapabilities(true, true), + ) // Register resource handlers - s.server.AddResource(mcp.NewResource( + s.AddResource(mcp.NewResource( "file://", "File System", mcp.WithResourceDescription("Access to files and directories on the local file system"), - ), s.handleReadResource) + ), h.handleReadResource) // Register tool handlers - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "read_file", mcp.WithDescription("Read the complete contents of a file from the file system."), mcp.WithString("path", mcp.Description("Path to the file to read"), mcp.Required(), ), - ), s.handleReadFile) + ), h.handleReadFile) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "write_file", mcp.WithDescription("Create a new file or overwrite an existing file with new content."), mcp.WithString("path", @@ -112,27 +48,27 @@ func NewFilesystemServer(allowedDirs []string) (*FilesystemServer, error) { mcp.Description("Content to write to the file"), mcp.Required(), ), - ), s.handleWriteFile) + ), h.handleWriteFile) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "list_directory", mcp.WithDescription("Get a detailed listing of all files and directories in a specified path."), mcp.WithString("path", mcp.Description("Path of the directory to list"), mcp.Required(), ), - ), s.handleListDirectory) + ), h.handleListDirectory) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "create_directory", mcp.WithDescription("Create a new directory or ensure a directory exists."), mcp.WithString("path", mcp.Description("Path of the directory to create"), mcp.Required(), ), - ), s.handleCreateDirectory) + ), h.handleCreateDirectory) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "copy_file", mcp.WithDescription("Copy files and directories."), mcp.WithString("source", @@ -143,9 +79,9 @@ func NewFilesystemServer(allowedDirs []string) (*FilesystemServer, error) { mcp.Description("Destination path"), mcp.Required(), ), - ), s.handleCopyFile) + ), h.handleCopyFile) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "move_file", mcp.WithDescription("Move or rename files and directories."), mcp.WithString("source", @@ -156,9 +92,9 @@ func NewFilesystemServer(allowedDirs []string) (*FilesystemServer, error) { mcp.Description("Destination path"), mcp.Required(), ), - ), s.handleMoveFile) + ), h.handleMoveFile) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "search_files", mcp.WithDescription("Recursively search for files and directories matching a pattern."), mcp.WithString("path", @@ -169,32 +105,32 @@ func NewFilesystemServer(allowedDirs []string) (*FilesystemServer, error) { mcp.Description("Search pattern to match against file names"), mcp.Required(), ), - ), s.handleSearchFiles) + ), h.handleSearchFiles) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "get_file_info", mcp.WithDescription("Retrieve detailed metadata about a file or directory."), mcp.WithString("path", mcp.Description("Path to the file or directory"), mcp.Required(), ), - ), s.handleGetFileInfo) + ), h.handleGetFileInfo) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "list_allowed_directories", mcp.WithDescription("Returns the list of directories that this server is allowed to access."), - ), s.handleListAllowedDirectories) + ), h.handleListAllowedDirectories) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "read_multiple_files", mcp.WithDescription("Read the contents of multiple files in a single operation."), mcp.WithArray("paths", mcp.Description("List of file paths to read"), mcp.Required(), ), - ), s.handleReadMultipleFiles) + ), h.handleReadMultipleFiles) - s.server.AddTool(mcp.NewTool( + s.AddTool(mcp.NewTool( "tree", mcp.WithDescription("Returns a hierarchical JSON representation of a directory structure."), mcp.WithString("path", @@ -207,1900 +143,7 @@ func NewFilesystemServer(allowedDirs []string) (*FilesystemServer, error) { mcp.WithBoolean("follow_symlinks", mcp.Description("Whether to follow symbolic links (default: false)"), ), - ), s.handleTree) + ), h.handleTree) return s, nil } - -// isPathInAllowedDirs checks if a path is within any of the allowed directories -func (s *FilesystemServer) isPathInAllowedDirs(path string) bool { - // Ensure path is absolute and clean - absPath, err := filepath.Abs(path) - if err != nil { - return false - } - - // Add trailing separator to ensure we're checking a directory or a file within a directory - // and not a prefix match (e.g., /tmp/foo should not match /tmp/foobar) - if !strings.HasSuffix(absPath, string(filepath.Separator)) { - // If it's a file, we need to check its directory - if info, err := os.Stat(absPath); err == nil && !info.IsDir() { - absPath = filepath.Dir(absPath) + string(filepath.Separator) - } else { - absPath = absPath + string(filepath.Separator) - } - } - - // Check if the path is within any of the allowed directories - for _, dir := range s.allowedDirs { - if strings.HasPrefix(absPath, dir) { - return true - } - } - return false -} - -// buildTree builds a tree representation of the filesystem starting at the given path -func (s *FilesystemServer) buildTree(path string, maxDepth int, currentDepth int, followSymlinks bool) (*FileNode, error) { - // Validate the path - validPath, err := s.validatePath(path) - if err != nil { - return nil, err - } - - // Get file info - info, err := os.Stat(validPath) - if err != nil { - return nil, err - } - - // Create the node - node := &FileNode{ - Name: filepath.Base(validPath), - Path: validPath, - Modified: info.ModTime(), - } - - // Set type and size - if info.IsDir() { - node.Type = "directory" - - // If we haven't reached the max depth, process children - if currentDepth < maxDepth { - // Read directory entries - entries, err := os.ReadDir(validPath) - if err != nil { - return nil, err - } - - // Process each entry - for _, entry := range entries { - entryPath := filepath.Join(validPath, entry.Name()) - - // Handle symlinks - if entry.Type()&os.ModeSymlink != 0 { - if !followSymlinks { - // Skip symlinks if not following them - continue - } - - // Resolve symlink - linkDest, err := filepath.EvalSymlinks(entryPath) - if err != nil { - // Skip invalid symlinks - continue - } - - // Validate the symlink destination is within allowed directories - if !s.isPathInAllowedDirs(linkDest) { - // Skip symlinks pointing outside allowed directories - continue - } - - entryPath = linkDest - } - - // Recursively build child node - childNode, err := s.buildTree(entryPath, maxDepth, currentDepth+1, followSymlinks) - if err != nil { - // Skip entries with errors - continue - } - - // Add child to the current node - node.Children = append(node.Children, childNode) - } - } - } else { - node.Type = "file" - node.Size = info.Size() - } - - return node, nil -} - -func (s *FilesystemServer) validatePath(requestedPath string) (string, error) { - // Always convert to absolute path first - abs, err := filepath.Abs(requestedPath) - if err != nil { - return "", fmt.Errorf("invalid path: %w", err) - } - - // Check if path is within allowed directories - if !s.isPathInAllowedDirs(abs) { - return "", fmt.Errorf( - "access denied - path outside allowed directories: %s", - abs, - ) - } - - // Handle symlinks - realPath, err := filepath.EvalSymlinks(abs) - if err != nil { - if !os.IsNotExist(err) { - return "", err - } - // For new files, check parent directory - parent := filepath.Dir(abs) - realParent, err := filepath.EvalSymlinks(parent) - if err != nil { - return "", fmt.Errorf("parent directory does not exist: %s", parent) - } - - if !s.isPathInAllowedDirs(realParent) { - return "", fmt.Errorf( - "access denied - parent directory outside allowed directories", - ) - } - return abs, nil - } - - // Check if the real path (after resolving symlinks) is still within allowed directories - if !s.isPathInAllowedDirs(realPath) { - return "", fmt.Errorf( - "access denied - symlink target outside allowed directories", - ) - } - - return realPath, nil -} - -func (s *FilesystemServer) getFileStats(path string) (FileInfo, error) { - info, err := os.Stat(path) - if err != nil { - return FileInfo{}, err - } - - return FileInfo{ - Size: info.Size(), - Created: info.ModTime(), // Note: ModTime used as birth time isn't always available - Modified: info.ModTime(), - Accessed: info.ModTime(), // Note: Access time isn't always available - IsDirectory: info.IsDir(), - IsFile: !info.IsDir(), - Permissions: fmt.Sprintf("%o", info.Mode().Perm()), - }, nil -} - -func (s *FilesystemServer) searchFiles( - rootPath, pattern string, -) ([]string, error) { - var results []string - pattern = strings.ToLower(pattern) - - err := filepath.Walk( - rootPath, - func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil // Skip errors and continue - } - - // Try to validate path - if _, err := s.validatePath(path); err != nil { - return nil // Skip invalid paths - } - - if strings.Contains(strings.ToLower(info.Name()), pattern) { - results = append(results, path) - } - return nil - }, - ) - if err != nil { - return nil, err - } - return results, nil -} - -// detectMimeType tries to determine the MIME type of a file -func detectMimeType(path string) string { - // Use mimetype library for more accurate detection - mtype, err := mimetype.DetectFile(path) - if err != nil { - // Fallback to extension-based detection if file can't be read - ext := filepath.Ext(path) - if ext != "" { - mimeType := mime.TypeByExtension(ext) - if mimeType != "" { - return mimeType - } - } - return "application/octet-stream" // Default - } - - return mtype.String() -} - -// isTextFile determines if a file is likely a text file based on MIME type -func isTextFile(mimeType string) bool { - // Check for common text MIME types - if strings.HasPrefix(mimeType, "text/") { - return true - } - - // Common application types that are text-based - textApplicationTypes := []string{ - "application/json", - "application/xml", - "application/javascript", - "application/x-javascript", - "application/typescript", - "application/x-typescript", - "application/x-yaml", - "application/yaml", - "application/toml", - "application/x-sh", - "application/x-shellscript", - } - - for _, textType := range textApplicationTypes { - if mimeType == textType { - return true - } - } - - // Check for +format types - if strings.Contains(mimeType, "+xml") || - strings.Contains(mimeType, "+json") || - strings.Contains(mimeType, "+yaml") { - return true - } - - // Common code file types that might be misidentified - if strings.HasPrefix(mimeType, "text/x-") { - return true - } - - if strings.HasPrefix(mimeType, "application/x-") && - (strings.Contains(mimeType, "script") || - strings.Contains(mimeType, "source") || - strings.Contains(mimeType, "code")) { - return true - } - - return false -} - -// isImageFile determines if a file is an image based on MIME type -func isImageFile(mimeType string) bool { - return strings.HasPrefix(mimeType, "image/") || - (mimeType == "application/xml" && strings.HasSuffix(strings.ToLower(mimeType), ".svg")) -} - -// pathToResourceURI converts a file path to a resource URI -func pathToResourceURI(path string) string { - return "file://" + path -} - -// Resource handler -func (s *FilesystemServer) handleReadResource( - ctx context.Context, - request mcp.ReadResourceRequest, -) ([]mcp.ResourceContents, error) { - uri := request.Params.URI - - // Check if it's a file:// URI - if !strings.HasPrefix(uri, "file://") { - return nil, fmt.Errorf("unsupported URI scheme: %s", uri) - } - - // Extract the path from the URI - path := strings.TrimPrefix(uri, "file://") - - // Validate the path - validPath, err := s.validatePath(path) - if err != nil { - return nil, err - } - - // Get file info - fileInfo, err := os.Stat(validPath) - if err != nil { - return nil, err - } - - // If it's a directory, return a listing - if fileInfo.IsDir() { - entries, err := os.ReadDir(validPath) - if err != nil { - return nil, err - } - - var result strings.Builder - result.WriteString(fmt.Sprintf("Directory listing for: %s\n\n", validPath)) - - for _, entry := range entries { - entryPath := filepath.Join(validPath, entry.Name()) - entryURI := pathToResourceURI(entryPath) - - if entry.IsDir() { - result.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", entry.Name(), entryURI)) - } else { - info, err := entry.Info() - if err == nil { - result.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", - entry.Name(), entryURI, info.Size())) - } else { - result.WriteString(fmt.Sprintf("[FILE] %s (%s)\n", entry.Name(), entryURI)) - } - } - } - - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: uri, - MIMEType: "text/plain", - Text: result.String(), - }, - }, nil - } - - // It's a file, determine how to handle it - mimeType := detectMimeType(validPath) - - // Check file size - if fileInfo.Size() > MAX_INLINE_SIZE { - // File is too large to inline, return a reference instead - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: uri, - MIMEType: "text/plain", - Text: fmt.Sprintf("File is too large to display inline (%d bytes). Use the read_file tool to access specific portions.", fileInfo.Size()), - }, - }, nil - } - - // Read the file content - content, err := os.ReadFile(validPath) - if err != nil { - return nil, err - } - - // Handle based on content type - if isTextFile(mimeType) { - // It's a text file, return as text - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: uri, - MIMEType: mimeType, - Text: string(content), - }, - }, nil - } else { - // It's a binary file - if fileInfo.Size() <= MAX_BASE64_SIZE { - // Small enough for base64 encoding - return []mcp.ResourceContents{ - mcp.BlobResourceContents{ - URI: uri, - MIMEType: mimeType, - Blob: base64.StdEncoding.EncodeToString(content), - }, - }, nil - } else { - // Too large for base64, return a reference - return []mcp.ResourceContents{ - mcp.TextResourceContents{ - URI: uri, - MIMEType: "text/plain", - Text: fmt.Sprintf("Binary file (%s, %d bytes). Use the read_file tool to access specific portions.", mimeType, fileInfo.Size()), - }, - }, nil - } - } -} - -// Tool handlers - -func (s *FilesystemServer) handleReadFile( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - path, ok := request.Params.Arguments["path"].(string) - if !ok { - return nil, fmt.Errorf("path must be a string") - } - - // Handle empty or relative paths like "." or "./" by converting to absolute path - if path == "." || path == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - path = cwd - } - - validPath, err := s.validatePath(path) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if it's a directory - info, err := os.Stat(validPath) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - if info.IsDir() { - // For directories, return a resource reference instead - resourceURI := pathToResourceURI(validPath) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("This is a directory. Use the resource URI to browse its contents: %s", resourceURI), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Directory: %s", validPath), - }, - }, - }, - }, nil - } - - // Determine MIME type - mimeType := detectMimeType(validPath) - - // Check file size - if info.Size() > MAX_INLINE_SIZE { - // File is too large to inline, return a resource reference - resourceURI := pathToResourceURI(validPath) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("File is too large to display inline (%d bytes). Access it via resource URI: %s", info.Size(), resourceURI), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Large file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), - }, - }, - }, - }, nil - } - - // Read file content - content, err := os.ReadFile(validPath) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error reading file: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if it's a text file - if isTextFile(mimeType) { - // It's a text file, return as text - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: string(content), - }, - }, - }, nil - } else if isImageFile(mimeType) { - // It's an image file, return as image content - if info.Size() <= MAX_BASE64_SIZE { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Image file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), - }, - mcp.ImageContent{ - Type: "image", - Data: base64.StdEncoding.EncodeToString(content), - MIMEType: mimeType, - }, - }, - }, nil - } else { - // Too large for base64, return a reference - resourceURI := pathToResourceURI(validPath) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Image file is too large to display inline (%d bytes). Access it via resource URI: %s", info.Size(), resourceURI), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Large image: %s (%s, %d bytes)", validPath, mimeType, info.Size()), - }, - }, - }, - }, nil - } - } else { - // It's another type of binary file - resourceURI := pathToResourceURI(validPath) - - if info.Size() <= MAX_BASE64_SIZE { - // Small enough for base64 encoding - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.BlobResourceContents{ - URI: resourceURI, - MIMEType: mimeType, - Blob: base64.StdEncoding.EncodeToString(content), - }, - }, - }, - }, nil - } else { - // Too large for base64, return a reference - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Binary file: %s (%s, %d bytes). Access it via resource URI: %s", validPath, mimeType, info.Size(), resourceURI), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", validPath, mimeType, info.Size()), - }, - }, - }, - }, nil - } - } -} - -func (s *FilesystemServer) handleWriteFile( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - path, ok := request.Params.Arguments["path"].(string) - if !ok { - return nil, fmt.Errorf("path must be a string") - } - content, ok := request.Params.Arguments["content"].(string) - if !ok { - return nil, fmt.Errorf("content must be a string") - } - - // Handle empty or relative paths like "." or "./" by converting to absolute path - if path == "." || path == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - path = cwd - } - - validPath, err := s.validatePath(path) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if it's a directory - if info, err := os.Stat(validPath); err == nil && info.IsDir() { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: "Error: Cannot write to a directory", - }, - }, - IsError: true, - }, nil - } - - // Create parent directories if they don't exist - parentDir := filepath.Dir(validPath) - if err := os.MkdirAll(parentDir, 0755); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error creating parent directories: %v", err), - }, - }, - IsError: true, - }, nil - } - - if err := os.WriteFile(validPath, []byte(content), 0644); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error writing file: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Get file info for the response - info, err := os.Stat(validPath) - if err != nil { - // File was written but we couldn't get info - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Successfully wrote to %s", path), - }, - }, - }, nil - } - - resourceURI := pathToResourceURI(validPath) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Successfully wrote %d bytes to %s", info.Size(), path), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("File: %s (%d bytes)", validPath, info.Size()), - }, - }, - }, - }, nil -} - -func (s *FilesystemServer) handleListDirectory( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - path, ok := request.Params.Arguments["path"].(string) - if !ok { - return nil, fmt.Errorf("path must be a string") - } - - // Handle empty or relative paths like "." or "./" by converting to absolute path - if path == "." || path == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - path = cwd - } - - validPath, err := s.validatePath(path) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if it's a directory - info, err := os.Stat(validPath) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - if !info.IsDir() { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: "Error: Path is not a directory", - }, - }, - IsError: true, - }, nil - } - - entries, err := os.ReadDir(validPath) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error reading directory: %v", err), - }, - }, - IsError: true, - }, nil - } - - var result strings.Builder - result.WriteString(fmt.Sprintf("Directory listing for: %s\n\n", validPath)) - - for _, entry := range entries { - entryPath := filepath.Join(validPath, entry.Name()) - resourceURI := pathToResourceURI(entryPath) - - if entry.IsDir() { - result.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", entry.Name(), resourceURI)) - } else { - info, err := entry.Info() - if err == nil { - result.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", - entry.Name(), resourceURI, info.Size())) - } else { - result.WriteString(fmt.Sprintf("[FILE] %s (%s)\n", entry.Name(), resourceURI)) - } - } - } - - // Return both text content and embedded resource - resourceURI := pathToResourceURI(validPath) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: result.String(), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Directory: %s", validPath), - }, - }, - }, - }, nil -} - -func (s *FilesystemServer) handleCreateDirectory( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - path, ok := request.Params.Arguments["path"].(string) - if !ok { - return nil, fmt.Errorf("path must be a string") - } - - // Handle empty or relative paths like "." or "./" by converting to absolute path - if path == "." || path == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - path = cwd - } - - validPath, err := s.validatePath(path) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if path already exists - if info, err := os.Stat(validPath); err == nil { - if info.IsDir() { - resourceURI := pathToResourceURI(validPath) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Directory already exists: %s", path), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Directory: %s", validPath), - }, - }, - }, - }, nil - } - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: Path exists but is not a directory: %s", path), - }, - }, - IsError: true, - }, nil - } - - if err := os.MkdirAll(validPath, 0755); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error creating directory: %v", err), - }, - }, - IsError: true, - }, nil - } - - resourceURI := pathToResourceURI(validPath) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Successfully created directory %s", path), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Directory: %s", validPath), - }, - }, - }, - }, nil -} - -func (s *FilesystemServer) handleCopyFile( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - source, ok := request.Params.Arguments["source"].(string) - if !ok { - return nil, fmt.Errorf("source must be a string") - } - destination, ok := request.Params.Arguments["destination"].(string) - if !ok { - return nil, fmt.Errorf("destination must be a string") - } - - // Handle empty or relative paths for source - if source == "." || source == "./" { - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - source = cwd - } - if destination == "." || destination == "./" { - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - destination = cwd - } - - validSource, err := s.validatePath(source) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error with source path: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if source exists - srcInfo, err := os.Stat(validSource) - if os.IsNotExist(err) { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: Source does not exist: %s", source), - }, - }, - IsError: true, - }, nil - } else if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error accessing source: %v", err), - }, - }, - IsError: true, - }, nil - } - - validDest, err := s.validatePath(destination) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error with destination path: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Create parent directory for destination if it doesn't exist - destDir := filepath.Dir(validDest) - if err := os.MkdirAll(destDir, 0755); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error creating destination directory: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Perform the copy operation based on whether source is a file or directory - if srcInfo.IsDir() { - // It's a directory, copy recursively - if err := copyDir(validSource, validDest); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error copying directory: %v", err), - }, - }, - IsError: true, - }, nil - } - } else { - // It's a file, copy directly - if err := copyFile(validSource, validDest); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error copying file: %v", err), - }, - }, - IsError: true, - }, nil - } - } - - resourceURI := pathToResourceURI(validDest) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf( - "Successfully copied %s to %s", - source, - destination, - ), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Copied file: %s", validDest), - }, - }, - }, - }, nil -} - -// copyFile copies a single file from src to dst -func copyFile(src, dst string) error { - // Open the source file - sourceFile, err := os.Open(src) - if err != nil { - return err - } - defer sourceFile.Close() - - // Create the destination file - destFile, err := os.Create(dst) - if err != nil { - return err - } - defer destFile.Close() - - // Copy the contents - if _, err := io.Copy(destFile, sourceFile); err != nil { - return err - } - - // Get source file mode - sourceInfo, err := os.Stat(src) - if err != nil { - return err - } - - // Set the same file mode on destination - return os.Chmod(dst, sourceInfo.Mode()) -} - -// copyDir recursively copies a directory tree from src to dst -func copyDir(src, dst string) error { - // Get properties of source dir - srcInfo, err := os.Stat(src) - if err != nil { - return err - } - - // Create the destination directory with the same permissions - if err = os.MkdirAll(dst, srcInfo.Mode()); err != nil { - return err - } - - // Read directory entries - entries, err := os.ReadDir(src) - if err != nil { - return err - } - - for _, entry := range entries { - srcPath := filepath.Join(src, entry.Name()) - dstPath := filepath.Join(dst, entry.Name()) - - // Handle symlinks - if entry.Type()&os.ModeSymlink != 0 { - // For simplicity, we'll skip symlinks in this implementation - continue - } - - // Recursively copy subdirectories or copy files - if entry.IsDir() { - if err = copyDir(srcPath, dstPath); err != nil { - return err - } - } else { - if err = copyFile(srcPath, dstPath); err != nil { - return err - } - } - } - - return nil -} - -func (s *FilesystemServer) handleMoveFile( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - source, ok := request.Params.Arguments["source"].(string) - if !ok { - return nil, fmt.Errorf("source must be a string") - } - destination, ok := request.Params.Arguments["destination"].(string) - if !ok { - return nil, fmt.Errorf("destination must be a string") - } - - // Handle empty or relative paths for source - if source == "." || source == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - source = cwd - } - - // Handle empty or relative paths for destination - if destination == "." || destination == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - destination = cwd - } - - validSource, err := s.validatePath(source) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error with source path: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if source exists - if _, err := os.Stat(validSource); os.IsNotExist(err) { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: Source does not exist: %s", source), - }, - }, - IsError: true, - }, nil - } - - validDest, err := s.validatePath(destination) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error with destination path: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Create parent directory for destination if it doesn't exist - destDir := filepath.Dir(validDest) - if err := os.MkdirAll(destDir, 0755); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error creating destination directory: %v", err), - }, - }, - IsError: true, - }, nil - } - - if err := os.Rename(validSource, validDest); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error moving file: %v", err), - }, - }, - IsError: true, - }, nil - } - - resourceURI := pathToResourceURI(validDest) - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf( - "Successfully moved %s to %s", - source, - destination, - ), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("Moved file: %s", validDest), - }, - }, - }, - }, nil -} - -func (s *FilesystemServer) handleSearchFiles( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - path, ok := request.Params.Arguments["path"].(string) - if !ok { - return nil, fmt.Errorf("path must be a string") - } - pattern, ok := request.Params.Arguments["pattern"].(string) - if !ok { - return nil, fmt.Errorf("pattern must be a string") - } - - // Handle empty or relative paths like "." or "./" by converting to absolute path - if path == "." || path == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - path = cwd - } - - validPath, err := s.validatePath(path) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if it's a directory - info, err := os.Stat(validPath) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - if !info.IsDir() { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: "Error: Search path must be a directory", - }, - }, - IsError: true, - }, nil - } - - results, err := s.searchFiles(validPath, pattern) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error searching files: %v", - err), - }, - }, - IsError: true, - }, nil - } - - if len(results) == 0 { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("No files found matching pattern '%s' in %s", pattern, path), - }, - }, - }, nil - } - - // Format results with resource URIs - var formattedResults strings.Builder - formattedResults.WriteString(fmt.Sprintf("Found %d results:\n\n", len(results))) - - for _, result := range results { - resourceURI := pathToResourceURI(result) - info, err := os.Stat(result) - if err == nil { - if info.IsDir() { - formattedResults.WriteString(fmt.Sprintf("[DIR] %s (%s)\n", result, resourceURI)) - } else { - formattedResults.WriteString(fmt.Sprintf("[FILE] %s (%s) - %d bytes\n", - result, resourceURI, info.Size())) - } - } else { - formattedResults.WriteString(fmt.Sprintf("%s (%s)\n", result, resourceURI)) - } - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: formattedResults.String(), - }, - }, - }, nil -} - -func (s *FilesystemServer) handleTree( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - path, ok := request.Params.Arguments["path"].(string) - if !ok { - return nil, fmt.Errorf("path must be a string") - } - - // Handle empty or relative paths like "." or "./" by converting to absolute path - if path == "." || path == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - path = cwd - } - - // Extract depth parameter (optional, default: 3) - depth := 3 // Default value - if depthParam, ok := request.Params.Arguments["depth"]; ok { - if d, ok := depthParam.(float64); ok { - depth = int(d) - } - } - - // Extract follow_symlinks parameter (optional, default: false) - followSymlinks := false // Default value - if followParam, ok := request.Params.Arguments["follow_symlinks"]; ok { - if f, ok := followParam.(bool); ok { - followSymlinks = f - } - } - - // Validate the path is within allowed directories - validPath, err := s.validatePath(path) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Check if it's a directory - info, err := os.Stat(validPath) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - if !info.IsDir() { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: "Error: The specified path is not a directory", - }, - }, - IsError: true, - }, nil - } - - // Build the tree structure - tree, err := s.buildTree(validPath, depth, 0, followSymlinks) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error building directory tree: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Convert to JSON - jsonData, err := json.MarshalIndent(tree, "", " ") - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error generating JSON: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Create resource URI for the directory - resourceURI := pathToResourceURI(validPath) - - // Return the result - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Directory tree for %s (max depth: %d):\n\n%s", validPath, depth, string(jsonData)), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "application/json", - Text: string(jsonData), - }, - }, - }, - }, nil -} - -func (s *FilesystemServer) handleGetFileInfo( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - path, ok := request.Params.Arguments["path"].(string) - if !ok { - return nil, fmt.Errorf("path must be a string") - } - - // Handle empty or relative paths like "." or "./" by converting to absolute path - if path == "." || path == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory: %v", err), - }, - }, - IsError: true, - }, nil - } - path = cwd - } - - validPath, err := s.validatePath(path) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error: %v", err), - }, - }, - IsError: true, - }, nil - } - - info, err := s.getFileStats(validPath) - if err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error getting file info: %v", err), - }, - }, - IsError: true, - }, nil - } - - // Get MIME type for files - mimeType := "directory" - if info.IsFile { - mimeType = detectMimeType(validPath) - } - - resourceURI := pathToResourceURI(validPath) - - // Determine file type text - var fileTypeText string - if info.IsDirectory { - fileTypeText = "Directory" - } else { - fileTypeText = "File" - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf( - "File information for: %s\n\nSize: %d bytes\nCreated: %s\nModified: %s\nAccessed: %s\nIsDirectory: %v\nIsFile: %v\nPermissions: %s\nMIME Type: %s\nResource URI: %s", - validPath, - info.Size, - info.Created.Format(time.RFC3339), - info.Modified.Format(time.RFC3339), - info.Accessed.Format(time.RFC3339), - info.IsDirectory, - info.IsFile, - info.Permissions, - mimeType, - resourceURI, - ), - }, - mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.TextResourceContents{ - URI: resourceURI, - MIMEType: "text/plain", - Text: fmt.Sprintf("%s: %s (%s, %d bytes)", - fileTypeText, - validPath, - mimeType, - info.Size), - }, - }, - }, - }, nil -} - -func (s *FilesystemServer) handleReadMultipleFiles( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - pathsParam, ok := request.Params.Arguments["paths"] - if !ok { - return nil, fmt.Errorf("paths parameter is required") - } - - // Convert the paths parameter to a string slice - pathsSlice, ok := pathsParam.([]interface{}) - if !ok { - return nil, fmt.Errorf("paths must be an array of strings") - } - - if len(pathsSlice) == 0 { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: "No files specified to read", - }, - }, - IsError: true, - }, nil - } - - // Maximum number of files to read in a single request - const maxFiles = 50 - if len(pathsSlice) > maxFiles { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Too many files requested. Maximum is %d files per request.", maxFiles), - }, - }, - IsError: true, - }, nil - } - - // Process each file - var results []mcp.Content - for _, pathInterface := range pathsSlice { - path, ok := pathInterface.(string) - if !ok { - return nil, fmt.Errorf("each path must be a string") - } - - // Handle empty or relative paths like "." or "./" by converting to absolute path - if path == "." || path == "./" { - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error resolving current directory for path '%s': %v", path, err), - }) - continue - } - path = cwd - } - - validPath, err := s.validatePath(path) - if err != nil { - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error with path '%s': %v", path, err), - }) - continue - } - - // Check if it's a directory - info, err := os.Stat(validPath) - if err != nil { - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error accessing '%s': %v", path, err), - }) - continue - } - - if info.IsDir() { - // For directories, return a resource reference instead - resourceURI := pathToResourceURI(validPath) - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("'%s' is a directory. Use list_directory tool or resource URI: %s", path, resourceURI), - }) - continue - } - - // Determine MIME type - mimeType := detectMimeType(validPath) - - // Check file size - if info.Size() > MAX_INLINE_SIZE { - // File is too large to inline, return a resource reference - resourceURI := pathToResourceURI(validPath) - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("File '%s' is too large to display inline (%d bytes). Access it via resource URI: %s", - path, info.Size(), resourceURI), - }) - continue - } - - // Read file content - content, err := os.ReadFile(validPath) - if err != nil { - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Error reading file '%s': %v", path, err), - }) - continue - } - - // Add file header - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("--- File: %s ---", path), - }) - - // Check if it's a text file - if isTextFile(mimeType) { - // It's a text file, return as text - results = append(results, mcp.TextContent{ - Type: "text", - Text: string(content), - }) - } else if isImageFile(mimeType) { - // It's an image file, return as image content - if info.Size() <= MAX_BASE64_SIZE { - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Image file: %s (%s, %d bytes)", path, mimeType, info.Size()), - }) - results = append(results, mcp.ImageContent{ - Type: "image", - Data: base64.StdEncoding.EncodeToString(content), - MIMEType: mimeType, - }) - } else { - // Too large for base64, return a reference - resourceURI := pathToResourceURI(validPath) - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Image file '%s' is too large to display inline (%d bytes). Access it via resource URI: %s", - path, info.Size(), resourceURI), - }) - } - } else { - // It's another type of binary file - resourceURI := pathToResourceURI(validPath) - - if info.Size() <= MAX_BASE64_SIZE { - // Small enough for base64 encoding - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Binary file: %s (%s, %d bytes)", path, mimeType, info.Size()), - }) - results = append(results, mcp.EmbeddedResource{ - Type: "resource", - Resource: mcp.BlobResourceContents{ - URI: resourceURI, - MIMEType: mimeType, - Blob: base64.StdEncoding.EncodeToString(content), - }, - }) - } else { - // Too large for base64, return a reference - results = append(results, mcp.TextContent{ - Type: "text", - Text: fmt.Sprintf("Binary file '%s' (%s, %d bytes). Access it via resource URI: %s", - path, mimeType, info.Size(), resourceURI), - }) - } - } - } - - return &mcp.CallToolResult{ - Content: results, - }, nil -} - -func (s *FilesystemServer) handleListAllowedDirectories( - ctx context.Context, - request mcp.CallToolRequest, -) (*mcp.CallToolResult, error) { - // Remove the trailing separator for display purposes - displayDirs := make([]string, len(s.allowedDirs)) - for i, dir := range s.allowedDirs { - displayDirs[i] = strings.TrimSuffix(dir, string(filepath.Separator)) - } - - var result strings.Builder - result.WriteString("Allowed directories:\n\n") - - for _, dir := range displayDirs { - resourceURI := pathToResourceURI(dir) - result.WriteString(fmt.Sprintf("%s (%s)\n", dir, resourceURI)) - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{ - mcp.TextContent{ - Type: "text", - Text: result.String(), - }, - }, - }, nil -} - -func (s *FilesystemServer) Serve() error { - return server.ServeStdio(s.server) -} \ No newline at end of file diff --git a/go.mod b/go.mod index 29c7f9f..eeba9a7 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,15 @@ go 1.23.2 require ( github.com/gabriel-vasile/mimetype v1.4.3 github.com/mark3labs/mcp-go v0.26.0 + github.com/stretchr/testify v1.9.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/cast v1.7.1 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/net v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bae01f5..c819acf 100644 --- a/go.sum +++ b/go.sum @@ -26,5 +26,7 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 5fa1e66..c30fc1e 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "os" "github.com/mark3labs/mcp-filesystem-server/filesystemserver" + "github.com/mark3labs/mcp-go/server" ) func main() { @@ -20,13 +21,14 @@ func main() { } // Create and start the server - fs, err := filesystemserver.NewFilesystemServer(os.Args[1:]) + fss, err := filesystemserver.NewFilesystemServer(os.Args[1:]) if err != nil { log.Fatalf("Failed to create server: %v", err) } // Serve requests - if err := fs.Serve(); err != nil { + if err := server.ServeStdio(fss); err != nil { log.Fatalf("Server error: %v", err) } -} \ No newline at end of file +} +