diff --git a/README.md b/README.md index 355d3d5..68e2eb1 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # MCP SSH Wingman -A Model Context Protocol (MCP) server that provides read-only access to Unix shell prompts via tmux. This enables AI assistants like Claude to safely observe terminal environments without executing commands. +A Model Context Protocol (MCP) server that provides read-only access to Unix shell prompts via tmux or GNU screen. This enables AI assistants like Claude to safely observe terminal environments without executing commands. ## Features - 🔒 **Read-only access** - Observe terminal content without execution risks -- 🖥️ **tmux integration** - Leverages tmux's session management for reliable terminal access +- 🖥️ **tmux & screen integration** - Leverages tmux or GNU screen session management for reliable terminal access +- 📺 **Multiple window support** - Access different windows/panes within your terminal sessions - 📜 **Scrollback history** - Access historical terminal output - 📊 **Terminal metadata** - Retrieve dimensions, current path, and session info - 🔌 **MCP protocol** - Standard protocol for AI assistant integration @@ -13,7 +14,8 @@ A Model Context Protocol (MCP) server that provides read-only access to Unix she ## Prerequisites - Go 1.21 or later -- tmux +- tmux (for tmux support) +- GNU screen (for screen support) ## Installation @@ -55,8 +57,14 @@ make install # Start with default tmux session name (mcp-wingman) ./bin/mcp-ssh-wingman -# Use a custom tmux session name -./bin/mcp-ssh-wingman --session my-session +# Use tmux with a custom session name +./bin/mcp-ssh-wingman --terminal tmux --session my-session + +# Use GNU screen with default session name +./bin/mcp-ssh-wingman --terminal screen + +# Use GNU screen with custom session name and specific window +./bin/mcp-ssh-wingman --terminal screen --session my-screen --window 2 ``` ### Integration with Claude Desktop @@ -65,12 +73,37 @@ Add the server to your Claude Desktop configuration file: **macOS**: `~/Library/Application Support/Claude/claude_desktop_config.json` +**For tmux:** +```json +{ + "mcpServers": { + "ssh-wingman": { + "command": "/usr/local/bin/mcp-ssh-wingman", + "args": ["--terminal", "tmux", "--session", "mcp-wingman"] + } + } +} +``` + +**For GNU screen:** +```json +{ + "mcpServers": { + "ssh-wingman": { + "command": "/usr/local/bin/mcp-ssh-wingman", + "args": ["--terminal", "screen", "--session", "mcp-wingman"] + } + } +} +``` + +**For screen with specific window:** ```json { "mcpServers": { "ssh-wingman": { "command": "/usr/local/bin/mcp-ssh-wingman", - "args": ["--session", "mcp-wingman"] + "args": ["--terminal", "screen", "--session", "my-screen", "--window", "2"] } } } @@ -83,7 +116,7 @@ Restart Claude Desktop after updating the configuration. The server exposes the following MCP tools: ### `read_terminal` -Read the current terminal content from the tmux session. +Read the current terminal content from the tmux/screen session. ```json { @@ -92,7 +125,7 @@ Read the current terminal content from the tmux session. ``` ### `read_scrollback` -Read scrollback history from the tmux session. +Read scrollback history from the tmux/screen session. ```json { @@ -112,6 +145,27 @@ Get information about the terminal (dimensions, current path, etc.). } ``` +### `list_windows` +List all windows/panes in the current session. + +```json +{ + "name": "list_windows" +} +``` + +### `set_window` +Set the active window/pane for subsequent operations. + +```json +{ + "name": "set_window", + "arguments": { + "window_id": "2" + } +} +``` + ## Available Resources ### `terminal://current` @@ -122,12 +176,21 @@ Terminal metadata and information. ## How It Works -The server creates or attaches to a tmux session and uses tmux's built-in commands to safely read terminal content: +The server creates or attaches to a tmux/screen session and uses their built-in commands to safely read terminal content: + +1. **Session Management**: Creates/attaches to a detached tmux or screen session +2. **Content Capture**: Uses `tmux capture-pane` or `screen hardcopy` to read visible content +3. **Multiple Windows**: Can switch between different windows/panes within the session +4. **Read-Only**: Never sends keystrokes or commands to the session +5. **MCP Protocol**: Exposes terminal content via standard MCP tools and resources + +### Screen-Specific Features -1. **Session Management**: Creates/attaches to a detached tmux session -2. **Content Capture**: Uses `tmux capture-pane` to read visible content -3. **Read-Only**: Never sends keystrokes or commands to the session -4. **MCP Protocol**: Exposes terminal content via standard MCP tools and resources +For GNU screen users, the implementation provides: +- **Existing Session Support**: Attach to your existing screen session with all your windows +- **Window Navigation**: List and switch between different screen windows +- **Backscroll Access**: Access your screen's scrollback buffer history +- **Multi-Window Workflow**: Perfect for users who run local screen with multiple remote connections ## Development diff --git a/cmd/mcp-ssh-wingman/main.go b/cmd/mcp-ssh-wingman/main.go index 9939932..64e4d65 100644 --- a/cmd/mcp-ssh-wingman/main.go +++ b/cmd/mcp-ssh-wingman/main.go @@ -15,7 +15,9 @@ var ( commit = "none" date = "unknown" - sessionName = flag.String("session", "mcp-wingman", "tmux session name to attach to") + sessionName = flag.String("session", "mcp-wingman", "terminal session name to attach to") + terminalType = flag.String("terminal", "tmux", "terminal multiplexer type: tmux or screen") + windowID = flag.String("window", "", "specific window/pane ID to attach to (optional)") versionFlag = flag.Bool("version", false, "print version and exit") ) @@ -35,9 +37,17 @@ func main() { // Log to stderr so it doesn't interfere with JSON-RPC on stdout log.SetOutput(os.Stderr) - log.Printf("Starting MCP server for tmux session: %s", *sessionName) + // Validate terminal type + if *terminalType != "tmux" && *terminalType != "screen" { + log.Fatalf("Invalid terminal type: %s. Must be 'tmux' or 'screen'", *terminalType) + } + + log.Printf("Starting MCP server for %s session: %s", *terminalType, *sessionName) + if *windowID != "" { + log.Printf("Targeting specific window/pane: %s", *windowID) + } - srv := server.NewServer(*sessionName, os.Stdin, os.Stdout) + srv := server.NewServer(*terminalType, *sessionName, *windowID, os.Stdin, os.Stdout) if err := srv.Start(); err != nil { log.Fatalf("Server error: %v", err) } diff --git a/examples/screen-config.md b/examples/screen-config.md new file mode 100644 index 0000000..14eb37e --- /dev/null +++ b/examples/screen-config.md @@ -0,0 +1,86 @@ +# Example Claude Desktop Configuration for Screen Users + +This directory contains example configuration files for different screen setups. + +## Basic Screen Configuration + +For users with a single screen session: + +```json +{ + "mcpServers": { + "ssh-wingman": { + "command": "/usr/local/bin/mcp-ssh-wingman", + "args": ["--terminal", "screen", "--session", "main"] + } + } +} +``` + +## Multi-Window Screen Configuration + +For users who want to target a specific window in their screen session: + +```json +{ + "mcpServers": { + "ssh-wingman": { + "command": "/usr/local/bin/mcp-ssh-wingman", + "args": ["--terminal", "screen", "--session", "main", "--window", "1"] + } + } +} +``` + +## Usage Tips for Screen Users + +### 1. Find Your Screen Session Name +```bash +screen -ls +``` + +### 2. List Windows in Your Session +Once connected via MCP, use the `list_windows` tool to see all available windows. + +### 3. Switch Between Windows +Use the `set_window` tool to switch to different windows: +```json +{ + "name": "set_window", + "arguments": { + "window_id": "2" + } +} +``` + +### 4. Access Scrollback History +Use the `read_scrollback` tool to access your screen's backscroll: +```json +{ + "name": "read_scrollback", + "arguments": { + "lines": 1000 + } +} +``` + +## Screen Session Setup + +If you don't have a screen session running, you can create one: + +```bash +# Create a new detached screen session +screen -dmS main + +# Or attach to create windows +screen -S main +# Then use Ctrl-A c to create new windows +# Use Ctrl-A d to detach +``` + +## Benefits for Screen Users + +- **Preserve Your Workflow**: Use your existing screen setup without changes +- **Multiple Remote Connections**: Perfect for users who connect to multiple servers through screen windows +- **Rich Scrollback**: Access all your historical output stored in screen's backscroll buffer +- **Window Management**: Easily navigate between different terminal environments diff --git a/internal/screen/manager.go b/internal/screen/manager.go new file mode 100644 index 0000000..6cf250a --- /dev/null +++ b/internal/screen/manager.go @@ -0,0 +1,416 @@ +package screen + +import ( + "bufio" + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +const ( + SessionPrefix = "mcp-wingman" + DefaultScrollback = 1000 +) + +// getScrollbackFromScreenrc reads the defscrollback setting from ~/.screenrc +// Returns the value and whether it was found in the file +func getScrollbackFromScreenrc() (int, bool) { + homeDir, err := os.UserHomeDir() + if err != nil { + return DefaultScrollback, false + } + + screenrcPath := filepath.Join(homeDir, ".screenrc") + file, err := os.Open(screenrcPath) + if err != nil { + return DefaultScrollback, false + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "defscrollback ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + if scrollback, err := strconv.Atoi(parts[1]); err == nil { + return scrollback, true + } + } + } + } + + return DefaultScrollback, false +} + +// GetMaxScrollback returns the maximum scrollback lines configured +func GetMaxScrollback() int { + scrollback, _ := getScrollbackFromScreenrc() + return scrollback +} + +// GetDefaultScrollback returns the default scrollback lines and the max limit +// If .screenrc has defscrollback, use that as default, otherwise use 1000 +func GetDefaultScrollback() (defaultLines int, maxLines int) { + configuredScrollback, found := getScrollbackFromScreenrc() + if found { + // User has configured defscrollback, use it as both default and max + return configuredScrollback, configuredScrollback + } + // No defscrollback found, default to 1000 but allow up to 1000 + return DefaultScrollback, DefaultScrollback +} + +// Manager handles screen session management +type Manager struct { + sessionName string + windowID string +} + +// NewManager creates a new screen manager +func NewManager(sessionName string) *Manager { + if sessionName == "" { + sessionName = SessionPrefix + } + return &Manager{ + sessionName: sessionName, + windowID: "", // Empty means current window + } +} + +// NewManagerWithWindow creates a new screen manager for a specific window +func NewManagerWithWindow(sessionName, windowID string) *Manager { + if sessionName == "" { + sessionName = SessionPrefix + } + return &Manager{ + sessionName: sessionName, + windowID: windowID, + } +} + +// EnsureSession ensures a screen session exists, creating it if necessary +func (m *Manager) EnsureSession() error { + // Check if session exists + exists, err := m.SessionExists() + if err != nil { + return fmt.Errorf("failed to check session: %w", err) + } + + if !exists { + // Create new session in detached mode + cmd := exec.Command("screen", "-dmS", m.sessionName) + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create screen session: %w", err) + } + } + + return nil +} + +// SessionExists checks if the screen session exists +func (m *Manager) SessionExists() (bool, error) { + sessions, err := ListSessions() + if err != nil { + return false, err + } + + for _, session := range sessions { + if session == m.sessionName { + return true, nil + } + } + return false, nil +} + +// CapturePane captures the current window content +func (m *Manager) CapturePane() (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + // Use screen's hardcopy command to capture content + sessionName := m.sessionName + var cmd *exec.Cmd + + if m.windowID != "" { + // Create a temporary file for hardcopy output + cmd = exec.Command("screen", "-S", sessionName, "-p", m.windowID, "-X", "hardcopy", "/tmp/screen_capture") + } else { + cmd = exec.Command("screen", "-S", sessionName, "-X", "hardcopy", "/tmp/screen_capture") + } + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("failed to capture screen content: %w (stderr: %s)", err, stderr.String()) + } + + // Read the captured content + readCmd := exec.Command("cat", "/tmp/screen_capture") + readCmd.Stdout = &stdout + readCmd.Stderr = &stderr + + err = readCmd.Run() + if err != nil { + return "", fmt.Errorf("failed to read captured content: %w (stderr: %s)", err, stderr.String()) + } + + // Clean up temporary file + exec.Command("rm", "/tmp/screen_capture").Run() + + return stdout.String(), nil +} + +// GetPaneInfo returns information about the current window +func (m *Manager) GetPaneInfo() (map[string]string, error) { + var stdout bytes.Buffer + + sessionTarget := m.sessionName + if m.windowID != "" { + sessionTarget = fmt.Sprintf("%s:%s", m.sessionName, m.windowID) + } + + // Get window information using screen's display command + // We'll use a combination of commands to get the information + cmd := exec.Command("screen", "-S", sessionTarget, "-Q", "info") + cmd.Stdout = &stdout + + err := cmd.Run() + if err != nil { + // Fallback to basic info if screen doesn't support -Q info + return map[string]string{ + "width": "80", // Default values + "height": "24", + "current_path": "unknown", + "window_id": m.windowID, + }, nil + } + + info := strings.TrimSpace(stdout.String()) + + // Parse screen info output (format varies by screen version) + // Basic implementation - can be enhanced based on actual screen output format + return map[string]string{ + "width": "80", // Screen doesn't easily expose dimensions + "height": "24", + "current_path": "unknown", // Screen doesn't track current path like tmux + "window_id": m.windowID, + "info": info, + }, nil +} + +// GetScrollbackHistory gets the scrollback history from the window +func (m *Manager) GetScrollbackHistory(lines int) (string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + sessionName := m.sessionName + var cmd *exec.Cmd + + if m.windowID != "" { + cmd = exec.Command("screen", "-S", sessionName, "-p", m.windowID, "-X", "hardcopy", "-h", "/tmp/screen_scrollback") + } else { + cmd = exec.Command("screen", "-S", sessionName, "-X", "hardcopy", "-h", "/tmp/screen_scrollback") + } + cmd.Stderr = &stderr + + err := cmd.Run() + if err != nil { + return "", fmt.Errorf("failed to capture scrollback: %w (stderr: %s)", err, stderr.String()) + } + + // Read and limit to requested number of lines + readCmd := exec.Command("tail", "-n", strconv.Itoa(lines), "/tmp/screen_scrollback") + readCmd.Stdout = &stdout + readCmd.Stderr = &stderr + + err = readCmd.Run() + if err != nil { + return "", fmt.Errorf("failed to read scrollback content: %w (stderr: %s)", err, stderr.String()) + } + + // Clean up temporary file + exec.Command("rm", "/tmp/screen_scrollback").Run() + + return stdout.String(), nil +} + +// ListSessions lists all screen sessions +func ListSessions() ([]string, error) { + var stdout bytes.Buffer + + cmd := exec.Command("screen", "-ls") + cmd.Stdout = &stdout + + err := cmd.Run() + if err != nil { + // screen -ls returns exit code 1 when no sessions exist + if exitErr, ok := err.(*exec.ExitError); ok { + if exitErr.ExitCode() == 1 { + return []string{}, nil + } + } + return nil, fmt.Errorf("failed to list sessions: %w", err) + } + + output := stdout.String() + lines := strings.Split(output, "\n") + var sessions []string + + for _, line := range lines { + line = strings.TrimSpace(line) + // Parse screen -ls output format: "PID.sessionname (Detached/Attached)" + if strings.Contains(line, ".") && (strings.Contains(line, "Detached") || strings.Contains(line, "Attached")) { + parts := strings.Fields(line) + if len(parts) > 0 { + sessionPart := parts[0] + if dotIndex := strings.Index(sessionPart, "."); dotIndex != -1 { + sessionName := sessionPart[dotIndex+1:] + sessions = append(sessions, sessionName) + } + } + } + } + + return sessions, nil +} + +// ListWindows lists all windows in the current session +func (m *Manager) ListWindows() ([]map[string]string, error) { + // For now, let's just use the original method that works but truncates + // This is safer than methods that might interfere with the user's session + // We can improve this later with a truly non-intrusive method + return m.listWindowsOriginal() +} + +// listWindowsOriginal is the original implementation that works but truncates +func (m *Manager) listWindowsOriginal() ([]map[string]string, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + // Use screen's windows command (may truncate with many windows) + cmd := exec.Command("screen", "-S", m.sessionName, "-Q", "windows") + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Set a wide terminal width to avoid truncation of window list + // Screen -Q windows output is limited by COLUMNS environment variable + cmd.Env = append(os.Environ(), "COLUMNS=500", "LINES=50") + + err := cmd.Run() + if err != nil { + return m.listWindowsFallback() + } + + output := strings.TrimSpace(stdout.String()) + if output == "" { + return m.listWindowsFallback() + } + + // Parse the window list + var windows []map[string]string + + // Split into fields (words) + fields := strings.Fields(output) + + // Map to store window data: windowNum -> title + windowData := make(map[int]string) + currentWindow := -1 + + // Process each field + for _, field := range fields { + // Check if field is a pure number (window ID) + if windowNum, err := strconv.Atoi(field); err == nil { + // This is a window number + windowData[windowNum] = "" // Initialize with empty title + currentWindow = windowNum + } else { + // This is a title/name for the current window + if currentWindow >= 0 { + // Handle indicators (* or -) in the field + if strings.HasSuffix(field, "*") || strings.HasSuffix(field, "-") { + indicator := field[len(field)-1:] + title := field[:len(field)-1] + if title != "" { + windowData[currentWindow] = title + indicator + } else { + windowData[currentWindow] = indicator + } + } else { + windowData[currentWindow] = field + } + } + } + } + + // Convert map to sorted slice of windows + var windowNums []int + for num := range windowData { + windowNums = append(windowNums, num) + } + + // Sort window numbers + for i := 0; i < len(windowNums)-1; i++ { + for j := i + 1; j < len(windowNums); j++ { + if windowNums[i] > windowNums[j] { + windowNums[i], windowNums[j] = windowNums[j], windowNums[i] + } + } + } + + // Build the result + for _, num := range windowNums { + title := windowData[num] + displayName := fmt.Sprintf("%d", num) + if title != "" { + displayName += " " + title + } + + windows = append(windows, map[string]string{ + "id": fmt.Sprintf("%d", num), + "name": displayName, + }) + } + + if len(windows) == 0 { + return m.listWindowsFallback() + } + + return windows, nil +} + +// listWindowsFallback provides a fallback method to list windows +func (m *Manager) listWindowsFallback() ([]map[string]string, error) { + // Basic fallback - assumes current window exists + return []map[string]string{ + { + "id": "0", + "name": "default", + }, + }, nil +} + +// KillSession kills the screen session +func (m *Manager) KillSession() error { + cmd := exec.Command("screen", "-S", m.sessionName, "-X", "quit") + return cmd.Run() +} + +// SetWindow sets the window ID for this manager +func (m *Manager) SetWindow(windowID string) { + m.windowID = windowID +} + +// ListSessions lists all screen sessions (implements SessionLister interface) +func (m *Manager) ListSessions() ([]string, error) { + return ListSessions() +} + +// GetWindow returns the current window ID +func (m *Manager) GetWindow() string { + return m.windowID +} diff --git a/internal/server/server.go b/internal/server/server.go index 863ad76..b1fb5f2 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -4,8 +4,11 @@ import ( "encoding/json" "fmt" "io" + "strings" "github.com/conall-obrien/mcp-ssh-wingman/internal/mcp" + "github.com/conall-obrien/mcp-ssh-wingman/internal/screen" + "github.com/conall-obrien/mcp-ssh-wingman/internal/terminal" "github.com/conall-obrien/mcp-ssh-wingman/internal/tmux" ) @@ -21,25 +24,46 @@ var ( // Server represents the MCP server type Server struct { - tmuxManager *tmux.Manager - reader io.Reader - writer io.Writer + terminalManager terminal.WindowManager + terminalType string + reader io.Reader + writer io.Writer } // NewServer creates a new MCP server instance -func NewServer(sessionName string, reader io.Reader, writer io.Writer) *Server { +func NewServer(terminalType, sessionName, windowID string, reader io.Reader, writer io.Writer) *Server { + var manager terminal.WindowManager + + switch terminalType { + case "screen": + screenManager := screen.NewManager(sessionName) + if windowID != "" { + screenManager.SetWindow(windowID) + } + manager = screenManager + case "tmux": + fallthrough + default: + tmuxManager := tmux.NewManager(sessionName) + if windowID != "" { + tmuxManager.SetWindow(windowID) + } + manager = tmuxManager + } + return &Server{ - tmuxManager: tmux.NewManager(sessionName), - reader: reader, - writer: writer, + terminalManager: manager, + terminalType: terminalType, + reader: reader, + writer: writer, } } // Start begins the server message loop func (s *Server) Start() error { - // Ensure tmux session exists - if err := s.tmuxManager.EnsureSession(); err != nil { - return fmt.Errorf("failed to setup tmux session: %w", err) + // Ensure terminal session exists + if err := s.terminalManager.EnsureSession(); err != nil { + return fmt.Errorf("failed to setup terminal session: %w", err) } decoder := json.NewDecoder(s.reader) @@ -137,42 +161,76 @@ func (s *Server) handleInitialize(request *mcp.JSONRPCRequest) (*mcp.InitializeR } func (s *Server) listTools() *mcp.ListToolsResult { - return &mcp.ListToolsResult{ - Tools: []mcp.Tool{ - { - Name: "read_terminal", - Description: "Read the current terminal content from the tmux session", - InputSchema: mcp.InputSchema{ - Type: "object", - Properties: map[string]mcp.Property{}, - Required: []string{}, - }, + // Get dynamic scrollback settings for screen + defaultScrollback := screen.DefaultScrollback + maxScrollback := screen.DefaultScrollback + if s.terminalType == "screen" { + defaultScrollback, maxScrollback = screen.GetDefaultScrollback() + } + + scrollbackDesc := fmt.Sprintf("Number of lines of scrollback history to retrieve (default: %d, max: %d)", defaultScrollback, maxScrollback) + + tools := []mcp.Tool{ + { + Name: "read_terminal", + Description: fmt.Sprintf("Read the current terminal content from the %s session", s.terminalType), + InputSchema: mcp.InputSchema{ + Type: "object", + Properties: map[string]mcp.Property{}, + Required: []string{}, }, - { - Name: "read_scrollback", - Description: "Read scrollback history from the tmux session", - InputSchema: mcp.InputSchema{ - Type: "object", - Properties: map[string]mcp.Property{ - "lines": { - Type: "number", - Description: "Number of lines of scrollback history to retrieve (default: 100)", - }, + }, + { + Name: "read_scrollback", + Description: fmt.Sprintf("Read scrollback history from the %s session", s.terminalType), + InputSchema: mcp.InputSchema{ + Type: "object", + Properties: map[string]mcp.Property{ + "lines": { + Type: "number", + Description: scrollbackDesc, }, - Required: []string{}, }, + Required: []string{}, }, - { - Name: "get_terminal_info", - Description: "Get information about the terminal (dimensions, current path, etc.)", - InputSchema: mcp.InputSchema{ - Type: "object", - Properties: map[string]mcp.Property{}, - Required: []string{}, + }, + { + Name: "get_terminal_info", + Description: "Get information about the terminal (dimensions, current path, etc.)", + InputSchema: mcp.InputSchema{ + Type: "object", + Properties: map[string]mcp.Property{}, + Required: []string{}, + }, + }, + { + Name: "list_windows", + Description: fmt.Sprintf("List all windows/panes in the %s session", s.terminalType), + InputSchema: mcp.InputSchema{ + Type: "object", + Properties: map[string]mcp.Property{}, + Required: []string{}, + }, + }, + { + Name: "set_window", + Description: fmt.Sprintf("Set the active window/pane in the %s session", s.terminalType), + InputSchema: mcp.InputSchema{ + Type: "object", + Properties: map[string]mcp.Property{ + "window_id": { + Type: "string", + Description: "The window/pane ID to switch to", + }, }, + Required: []string{"window_id"}, }, }, } + + return &mcp.ListToolsResult{ + Tools: tools, + } } func (s *Server) callTool(request *mcp.JSONRPCRequest) (*mcp.CallToolResult, error) { @@ -188,7 +246,7 @@ func (s *Server) callTool(request *mcp.JSONRPCRequest) (*mcp.CallToolResult, err switch toolRequest.Name { case "read_terminal": - content, err := s.tmuxManager.CapturePane() + content, err := s.terminalManager.CapturePane() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("Error: %s", err)}}, @@ -200,7 +258,13 @@ func (s *Server) callTool(request *mcp.JSONRPCRequest) (*mcp.CallToolResult, err }, nil case "read_scrollback": - lines := 100 // default + defaultScrollback := screen.DefaultScrollback + maxScrollback := screen.DefaultScrollback + if s.terminalType == "screen" { + defaultScrollback, maxScrollback = screen.GetDefaultScrollback() + } + + lines := defaultScrollback // use the appropriate default based on .screenrc if linesVal, ok := toolRequest.Arguments["lines"]; ok { switch v := linesVal.(type) { case float64: @@ -210,7 +274,12 @@ func (s *Server) callTool(request *mcp.JSONRPCRequest) (*mcp.CallToolResult, err } } - content, err := s.tmuxManager.GetScrollbackHistory(lines) + // Cap at configured scrollback limit + if lines > maxScrollback { + lines = maxScrollback + } + + content, err := s.terminalManager.GetScrollbackHistory(lines) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("Error: %s", err)}}, @@ -222,7 +291,7 @@ func (s *Server) callTool(request *mcp.JSONRPCRequest) (*mcp.CallToolResult, err }, nil case "get_terminal_info": - info, err := s.tmuxManager.GetPaneInfo() + info, err := s.terminalManager.GetPaneInfo() if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("Error: %s", err)}}, @@ -230,13 +299,46 @@ func (s *Server) callTool(request *mcp.JSONRPCRequest) (*mcp.CallToolResult, err }, nil } - infoText := fmt.Sprintf("Terminal Info:\n- Width: %s\n- Height: %s\n- Current Path: %s\n- Pane Index: %s", - info["width"], info["height"], info["current_path"], info["pane_index"]) + infoText := fmt.Sprintf("Terminal Info (%s):\n- Width: %s\n- Height: %s\n- Current Path: %s\n- Window/Pane ID: %s", + s.terminalType, info["width"], info["height"], info["current_path"], s.terminalManager.GetWindow()) return &mcp.CallToolResult{ Content: []mcp.Content{{Type: "text", Text: infoText}}, }, nil + case "list_windows": + windows, err := s.terminalManager.ListWindows() + if err != nil { + return &mcp.CallToolResult{ + Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("Error: %s", err)}}, + IsError: true, + }, nil + } + + var windowList strings.Builder + windowList.WriteString(fmt.Sprintf("Available windows/panes in %s session:\n", s.terminalType)) + for _, window := range windows { + windowList.WriteString(fmt.Sprintf("- ID: %s, Name: %s\n", window["id"], window["name"])) + } + + return &mcp.CallToolResult{ + Content: []mcp.Content{{Type: "text", Text: windowList.String()}}, + }, nil + + case "set_window": + windowID, ok := toolRequest.Arguments["window_id"].(string) + if !ok { + return &mcp.CallToolResult{ + Content: []mcp.Content{{Type: "text", Text: "Error: window_id must be a string"}}, + IsError: true, + }, nil + } + + s.terminalManager.SetWindow(windowID) + return &mcp.CallToolResult{ + Content: []mcp.Content{{Type: "text", Text: fmt.Sprintf("Switched to window/pane: %s", windowID)}}, + }, nil + default: return nil, fmt.Errorf("unknown tool: %s", toolRequest.Name) } @@ -274,7 +376,7 @@ func (s *Server) readResource(request *mcp.JSONRPCRequest) (*mcp.ReadResourceRes switch resourceRequest.URI { case "terminal://current": - content, err := s.tmuxManager.CapturePane() + content, err := s.terminalManager.CapturePane() if err != nil { return nil, err } @@ -289,12 +391,12 @@ func (s *Server) readResource(request *mcp.JSONRPCRequest) (*mcp.ReadResourceRes }, nil case "terminal://info": - info, err := s.tmuxManager.GetPaneInfo() + info, err := s.terminalManager.GetPaneInfo() if err != nil { return nil, err } - infoText := fmt.Sprintf("Terminal Information:\n\nDimensions: %sx%s\nCurrent Path: %s\nPane Index: %s", - info["width"], info["height"], info["current_path"], info["pane_index"]) + infoText := fmt.Sprintf("Terminal Information (%s):\n\nDimensions: %sx%s\nCurrent Path: %s\nWindow/Pane ID: %s", + s.terminalType, info["width"], info["height"], info["current_path"], s.terminalManager.GetWindow()) return &mcp.ReadResourceResult{ Contents: []mcp.ResourceContent{ diff --git a/internal/terminal/interface.go b/internal/terminal/interface.go new file mode 100644 index 0000000..b0343dd --- /dev/null +++ b/internal/terminal/interface.go @@ -0,0 +1,42 @@ +package terminal + +// Manager defines the interface for terminal session managers (tmux, screen, etc.) +type Manager interface { + // EnsureSession ensures a terminal session exists, creating it if necessary + EnsureSession() error + + // SessionExists checks if the terminal session exists + SessionExists() (bool, error) + + // CapturePane captures the current pane/window content + CapturePane() (string, error) + + // GetPaneInfo returns information about the current pane/window + GetPaneInfo() (map[string]string, error) + + // GetScrollbackHistory gets the scrollback history + GetScrollbackHistory(lines int) (string, error) + + // KillSession kills the terminal session + KillSession() error +} + +// WindowManager extends Manager with window/pane selection capabilities +type WindowManager interface { + Manager + + // ListWindows lists all windows/panes in the session + ListWindows() ([]map[string]string, error) + + // SetWindow sets the active window/pane + SetWindow(windowID string) + + // GetWindow returns the current window/pane ID + GetWindow() string +} + +// SessionLister provides session listing capabilities +type SessionLister interface { + // ListSessions lists all available sessions + ListSessions() ([]string, error) +} diff --git a/internal/tmux/manager.go b/internal/tmux/manager.go index f3657a6..6496d50 100644 --- a/internal/tmux/manager.go +++ b/internal/tmux/manager.go @@ -153,3 +153,56 @@ func (m *Manager) KillSession() error { cmd := exec.Command("tmux", "kill-session", "-t", m.sessionName) return cmd.Run() } + +// ListWindows lists all windows in the tmux session (implements WindowManager interface) +func (m *Manager) ListWindows() ([]map[string]string, error) { + var stdout bytes.Buffer + + cmd := exec.Command("tmux", "list-windows", "-t", m.sessionName, "-F", "#{window_index},#{window_name}") + cmd.Stdout = &stdout + + err := cmd.Run() + if err != nil { + return nil, fmt.Errorf("failed to list windows: %w", err) + } + + var windows []map[string]string + lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") + + for _, line := range lines { + if line == "" { + continue + } + parts := strings.Split(line, ",") + if len(parts) >= 2 { + windows = append(windows, map[string]string{ + "id": parts[0], + "name": parts[1], + }) + } + } + + return windows, nil +} + +// SetWindow sets the window for this manager (implements WindowManager interface) +func (m *Manager) SetWindow(windowID string) { + // For tmux, we can modify the session target to include window + if windowID != "" { + m.sessionName = fmt.Sprintf("%s:%s", strings.Split(m.sessionName, ":")[0], windowID) + } +} + +// GetWindow returns the current window ID (implements WindowManager interface) +func (m *Manager) GetWindow() string { + parts := strings.Split(m.sessionName, ":") + if len(parts) > 1 { + return parts[1] + } + return "" +} + +// ListSessions lists all tmux sessions (implements SessionLister interface) +func (m *Manager) ListSessions() ([]string, error) { + return ListSessions() +}