Skip to main content
Glama

MCP Clipboard Server

MIT License
8
3
  • Apple
  • Linux
main.go18.7 kB
package main import ( "context" "crypto/md5" "encoding/base64" "encoding/hex" "fmt" "os" "os/exec" "os/signal" "path/filepath" "runtime" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "github.com/atotto/clipboard" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" ) const ( DefaultCleanupTTL = 1 * time.Hour FilenamePrefix = "mcp-clip-" MaxCASRetries = 1000 // Maximum retries for compare-and-swap operations ) type clipboardState struct { content string time time.Time } type ClipboardServer struct { lastClipboard atomic.Value // stores clipboardState running int32 // atomic flag for monitoring state cancel atomic.Pointer[context.CancelFunc] // FIXED: Now uses atomic pointer sessionFiles []string // track files created during this session filesMutex sync.Mutex // protect sessionFiles slice } func NewClipboardServer() *ClipboardServer { cs := &ClipboardServer{} cs.lastClipboard.Store(clipboardState{}) return cs } // updateClipboard atomically updates clipboard state using CAS loop to prevent race conditions. // Returns true if content changed, false if content was already present. func (cs *ClipboardServer) updateClipboard(content string) bool { if content == "" { return false } // Atomic compare-and-swap loop with retry limit for memory safety for retries := 0; retries < MaxCASRetries; retries++ { current := cs.lastClipboard.Load() currentState, ok := current.(clipboardState) if !ok { // Fallback for uninitialized state currentState = clipboardState{} } // Only update if content has changed if content == currentState.content { return false } newState := clipboardState{ content: content, time: time.Now(), } // Atomic compare-and-swap ensures no race condition if cs.lastClipboard.CompareAndSwap(current, newState) { return true } // If CAS failed, another goroutine updated the state, retry } // Fallback to simple store if max retries exceeded (extremely unlikely) if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Warning: CAS retry limit exceeded in updateClipboard, using fallback\n") } cs.lastClipboard.Store(clipboardState{ content: content, time: time.Now(), }) return true } func (cs *ClipboardServer) getLastClipboard() (string, time.Time) { if state, ok := cs.lastClipboard.Load().(clipboardState); ok { return state.content, state.time } return "", time.Time{} } func (cs *ClipboardServer) addSessionFile(filePath string) { cs.filesMutex.Lock() defer cs.filesMutex.Unlock() // Don't track files during shutdown to prevent race condition if atomic.LoadInt32(&cs.running) == 0 { return } cs.sessionFiles = append(cs.sessionFiles, filePath) } func (cs *ClipboardServer) cleanupSessionFiles() { cs.filesMutex.Lock() files := make([]string, len(cs.sessionFiles)) copy(files, cs.sessionFiles) cs.sessionFiles = nil cs.filesMutex.Unlock() var removed, errors int for _, filePath := range files { if err := os.Remove(filePath); err != nil { errors++ if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Failed to remove session file %s: %v\n", filePath, err) } } else { removed++ if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Removed session file: %s\n", filePath) } } } if os.Getenv("MCP_DEBUG") == "1" && (removed > 0 || errors > 0) { fmt.Fprintf(os.Stderr, "Session cleanup: %d removed, %d errors\n", removed, errors) } } func (cs *ClipboardServer) stop() { if atomic.CompareAndSwapInt32(&cs.running, 1, 0) { // Clean up session files on graceful shutdown cs.cleanupSessionFiles() // FIXED: Use atomic pointer load if cancelPtr := cs.cancel.Load(); cancelPtr != nil { (*cancelPtr)() } } } func main() { if len(os.Args) > 1 { switch os.Args[1] { case "-h", "--help": printUsage() return case "test": handleTestCommand() return case "version": fmt.Println("MCP Clipboard Server v1.0.0") return default: if strings.HasPrefix(os.Args[1], "-") { fmt.Printf("Unknown flag: %s\n", os.Args[1]) printUsage() return } } } if isRunningFromCLI() { fmt.Printf("MCP Clipboard Server v1.0.0\n") fmt.Printf("This is an MCP (Model Context Protocol) server for clipboard access.\n") fmt.Printf("It should be run by an MCP client, not directly from the command line.\n\n") printUsage() return } clipboardServer := NewClipboardServer() // Cleanup orphaned temp files from previous instances on startup if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Running startup cleanup...\n") } if err := cleanupExpiredFiles(); err != nil { // Log warning but don't fail startup if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Startup cleanup warning: %v\n", err) } } s := server.NewMCPServer( "mcp-clip", "1.0.0", server.WithToolCapabilities(true), ) readClipboardTool := mcp.NewTool("read_clipboard", mcp.WithDescription("Read the current clipboard content, supporting text and images"), mcp.WithString("format", mcp.Description("Format to return clipboard content in: 'text', 'base64', or 'auto' (default)"), ), ) s.AddTool(readClipboardTool, clipboardServer.readClipboardHandler) // Setup graceful shutdown ctx, cancel := context.WithCancel(context.Background()) clipboardServer.cancel.Store(&cancel) // Handle shutdown signals sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { <-sigChan clipboardServer.stop() cancel() }() // Start clipboard monitoring with context go func() { defer func() { if r := recover(); r != nil { fmt.Fprintf(os.Stderr, "Clipboard monitoring panic: %v\n", r) } }() clipboardServer.startClipboardMonitoring(ctx) }() if err := server.ServeStdio(s); err != nil { clipboardServer.stop() fmt.Fprintf(os.Stderr, "Fatal MCP server error: %v\n", err) os.Exit(1) } } func (cs *ClipboardServer) readClipboardHandler(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { format := "auto" if f := request.GetString("format", "auto"); f != "" { format = f } content, err := readClipboard() if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to read clipboard: %v", err)), nil } if content == "" { return mcp.NewToolResultText("Clipboard is empty"), nil } const maxDirectOutput = 25000 switch format { case "text": if len(content) > maxDirectOutput { filePath, err := saveToTempFile([]byte(content), "txt", cs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to save large content to temp file: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Clipboard text content too large (%d bytes). Saved to: %s", len(content), filePath)), nil } return mcp.NewToolResultText(content), nil case "base64": encoded := base64.StdEncoding.EncodeToString([]byte(content)) if len(encoded) > maxDirectOutput { filePath, err := saveToTempFile([]byte(encoded), "b64", cs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to save large base64 content to temp file: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Base64 encoded clipboard content too large (%d bytes). Saved to: %s", len(encoded), filePath)), nil } return mcp.NewToolResultText(fmt.Sprintf("Base64 encoded clipboard content:\n%s", encoded)), nil case "auto": if isProbablyText(content) { if len(content) > maxDirectOutput { filePath, err := saveToTempFile([]byte(content), "txt", cs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to save large text content to temp file: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Clipboard text content too large (%d bytes). Saved to: %s", len(content), filePath)), nil } return mcp.NewToolResultText(fmt.Sprintf("Clipboard text content:\n%s", content)), nil } else { return handleBinaryContent([]byte(content), cs) } default: return mcp.NewToolResultError(fmt.Sprintf("Unknown format: %s. Use 'text', 'base64', or 'auto'", format)), nil } } func (cs *ClipboardServer) startClipboardMonitoring(ctx context.Context) { // Set running state atomically if !atomic.CompareAndSwapInt32(&cs.running, 0, 1) { return // Already running } defer atomic.StoreInt32(&cs.running, 0) ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { select { case <-ctx.Done(): return // Graceful shutdown case <-ticker.C: content, err := readClipboard() if err != nil { // In debug mode, we could log this error if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Clipboard read error: %v\n", err) } continue } // Use lock-free update cs.updateClipboard(content) } } } func readClipboard() (string, error) { if isWSL2() { data, err := readClipboardDataWSL2() if err != nil { return "", err } return string(data), nil } return clipboard.ReadAll() } func readClipboardDataWSL2() ([]byte, error) { powershellPath := findPowerShell() if powershellPath == "" { return nil, fmt.Errorf("PowerShell not found - required for WSL2 clipboard access") } textCmd := exec.Command(powershellPath, "-Command", "Get-Clipboard -Raw") textOutput, textErr := textCmd.Output() if textErr == nil && len(textOutput) > 0 { content := strings.TrimSpace(string(textOutput)) if content != "" { return []byte(content), nil } } imageCmd := exec.Command(powershellPath, "-Command", ` $image = Get-Clipboard -Format Image if ($image -ne $null) { $ms = New-Object System.IO.MemoryStream $image.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) [Convert]::ToBase64String($ms.ToArray()) } `) imageOutput, imageErr := imageCmd.Output() if imageErr == nil && len(imageOutput) > 0 { content := strings.TrimSpace(string(imageOutput)) if content != "" { data, err := base64.StdEncoding.DecodeString(content) if err != nil { return nil, fmt.Errorf("failed to decode base64 image data: %v", err) } return data, nil } } return []byte{}, nil } func isWSL2() bool { if runtime.GOOS != "linux" { return false } if _, err := os.Stat("/proc/version"); err != nil { return false } content, err := os.ReadFile("/proc/version") if err != nil { return false } return strings.Contains(strings.ToLower(string(content)), "microsoft") || strings.Contains(strings.ToLower(string(content)), "wsl") } func findPowerShell() string { powershellPaths := []string{ "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/powershell.exe", "/mnt/c/windows/system32/windowspowershell/v1.0/powershell.exe", } for _, path := range powershellPaths { if _, err := os.Stat(path); err == nil { return path } } cmd := exec.Command("which", "powershell.exe") if output, err := cmd.Output(); err == nil { return strings.TrimSpace(string(output)) } return "" } func getCleanupTTL() time.Duration { if ttlStr := os.Getenv("MCP_CLEANUP_TTL"); ttlStr != "" { if ttl, err := time.ParseDuration(ttlStr); err == nil { if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Using custom cleanup TTL: %v\n", ttl) } return ttl } else { if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Invalid MCP_CLEANUP_TTL format '%s', using default: %v\n", ttlStr, DefaultCleanupTTL) } } } return DefaultCleanupTTL } func cleanupExpiredFiles() error { tempDir := os.TempDir() ttl := getCleanupTTL() cutoffTime := time.Now().Add(-ttl) files, err := filepath.Glob(filepath.Join(tempDir, FilenamePrefix+"*")) if err != nil { return fmt.Errorf("failed to list temp files: %v", err) } var removed, errors int for _, filePath := range files { if shouldRemoveFile(filePath, cutoffTime) { if err := os.Remove(filePath); err != nil { errors++ if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Failed to remove expired file %s: %v\n", filePath, err) } } else { removed++ if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Removed expired file: %s\n", filePath) } } } } if os.Getenv("MCP_DEBUG") == "1" && (removed > 0 || errors > 0) { fmt.Fprintf(os.Stderr, "Cleanup complete: %d removed, %d errors\n", removed, errors) } return nil } func shouldRemoveFile(filePath string, cutoffTime time.Time) bool { filename := filepath.Base(filePath) // Extract timestamp from filename: mcp-clip-{timestamp}-{hash}.{ext} if !strings.HasPrefix(filename, FilenamePrefix) { return false } parts := strings.Split(strings.TrimPrefix(filename, FilenamePrefix), "-") if len(parts) < 2 { // Old format without timestamp, remove it return true } timestamp, err := strconv.ParseInt(parts[0], 10, 64) if err != nil { // Invalid timestamp format, remove it return true } fileTime := time.Unix(timestamp, 0) return fileTime.Before(cutoffTime) } func saveToTempFile(data []byte, extension string, cs *ClipboardServer) (string, error) { // Clean up expired files before creating new ones if err := cleanupExpiredFiles(); err != nil { // Log error but don't fail - cleanup is best effort if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Cleanup warning: %v\n", err) } } hash := md5.Sum(data) timestamp := time.Now().Unix() filename := fmt.Sprintf("mcp-clip-%d-%s.%s", timestamp, hex.EncodeToString(hash[:]), extension) tempDir := os.TempDir() filePath := filepath.Join(tempDir, filename) // FIXED: Use atomic file creation with O_CREATE|O_EXCL to prevent TOCTOU race file, err := os.OpenFile(filePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) if err != nil { if os.IsExist(err) { // File already exists, return the path return filePath, nil } return "", fmt.Errorf("failed to create temp file %s (extension: %s, size: %d bytes, tempDir: %s): %v", filePath, extension, len(data), tempDir, err) } defer file.Close() if _, err := file.Write(data); err != nil { // Clean up partially created file os.Remove(filePath) return "", fmt.Errorf("failed to write temp file %s (extension: %s, size: %d bytes): %v", filePath, extension, len(data), err) } if os.Getenv("MCP_DEBUG") == "1" { fmt.Fprintf(os.Stderr, "Created temp file: %s (%d bytes)\n", filePath, len(data)) } // Track file for session cleanup if server instance provided if cs != nil { cs.addSessionFile(filePath) } return filePath, nil } func handleBinaryContent(data []byte, cs *ClipboardServer) (*mcp.CallToolResult, error) { isImage, imageType := detectImageType(data) if isImage { filePath, err := saveToTempFile(data, imageType, cs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to save image to temp file: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Clipboard image content (%s, %d bytes). Saved to: %s", imageType, len(data), filePath)), nil } encoded := base64.StdEncoding.EncodeToString(data) const maxDirectOutput = 25000 if len(encoded) > maxDirectOutput { filePath, err := saveToTempFile([]byte(encoded), "b64", cs) if err != nil { return mcp.NewToolResultError(fmt.Sprintf("Failed to save large binary content to temp file: %v", err)), nil } return mcp.NewToolResultText(fmt.Sprintf("Clipboard binary content too large (%d bytes base64). Saved to: %s", len(encoded), filePath)), nil } return mcp.NewToolResultText(fmt.Sprintf("Clipboard binary content (base64 encoded):\n%s", encoded)), nil } func detectImageType(data []byte) (bool, string) { if len(data) < 8 { return false, "" } if data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 { return true, "png" } if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { return true, "jpg" } if len(data) >= 6 && string(data[0:6]) == "GIF87a" || string(data[0:6]) == "GIF89a" { return true, "gif" } if len(data) >= 12 && string(data[8:12]) == "WEBP" { return true, "webp" } if data[0] == 0x42 && data[1] == 0x4D { return true, "bmp" } return false, "" } func isProbablyText(content string) bool { if len(content) == 0 { return true } textChars := 0 for _, r := range content { if r >= 32 && r <= 126 || r == '\n' || r == '\r' || r == '\t' { textChars++ } } return float64(textChars)/float64(len(content)) > 0.8 } func isRunningFromCLI() bool { if fileInfo, err := os.Stdin.Stat(); err == nil { return (fileInfo.Mode() & os.ModeCharDevice) != 0 } return true } func printUsage() { fmt.Printf(`USAGE: This MCP server provides clipboard access for MCP clients like Claude Desktop. For direct testing: %s --help Show this help message %s test Test clipboard functionality %s version Show version information For MCP client usage: 1. Build the server: go build -o mcp-clip 2. Add to your MCP client configuration: Claude Desktop: Add to claude_desktop_config.json { "mcpServers": { "mcp-clip": { "command": "/path/to/mcp-clip" } } } 3. Start your MCP client (Claude Desktop, etc.) Available Tools: - read_clipboard: Read clipboard content (text/images as base64) Features: - Automatic clipboard monitoring with notifications - Support for text and binary clipboard content - Base64 encoding for binary data (like images) - Smart content type detection Environment Variables: - MCP_DEBUG=1: Enable debug logging For more information about MCP: https://modelcontextprotocol.io/ `, os.Args[0], os.Args[0], os.Args[0]) } func handleTestCommand() { fmt.Println("Testing clipboard functionality...") content, err := readClipboard() if err != nil { fmt.Printf("❌ Failed to read clipboard: %v\n", err) return } if content == "" { fmt.Println("📋 Clipboard is empty") return } fmt.Printf("📋 Clipboard content detected (%d bytes)\n", len(content)) if isProbablyText(content) { fmt.Println("📝 Content type: Text") if len(content) <= 100 { fmt.Printf("📄 Content: %s\n", content) } else { fmt.Printf("📄 Content preview: %s...\n", content[:100]) } } else { fmt.Println("🖼️ Content type: Binary (possibly image)") fmt.Printf("📦 Base64 preview: %s...\n", base64.StdEncoding.EncodeToString([]byte(content))[:50]) } fmt.Println("✅ Clipboard test completed successfully") }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/standardbeagle/mcp-clip'

If you have feedback or need assistance with the MCP directory API, please join our Discord server