Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions shell/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os/exec"
"strconv"
"strings"
"syscall"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
Expand All @@ -29,6 +30,24 @@ type ExecuteCommandOutput struct {
Error string `json:"error,omitempty" jsonschema:"error message if execution failed"`
}

// Interactive flags that commonly cause commands to hang
var interactiveFlags = []string{
"-i", "--interactive", "--tty", "-t",
"vim", "vi", "nano", "emacs", "less", "more", "top", "htop",
"ftp", "sftp", "ssh", "ping", "tail -f", "tail -F",
}

// isInteractiveCommand checks if the script contains interactive commands
func isInteractiveCommand(script string) bool {
scriptLower := strings.ToLower(script)
for _, flag := range interactiveFlags {
if strings.Contains(scriptLower, flag) {
return true
}
}
return false
}

// getShellCommand returns the shell command to use, defaulting to "sh" if not set
func getShellCommand() string {
shellCmd := os.Getenv("SHELL_CMD")
Expand Down Expand Up @@ -70,11 +89,17 @@ func ExecuteCommand(ctx context.Context, req *mcp.CallToolRequest, input Execute
timeout = getTimeout()
}

// Warn about interactive commands (but still attempt execution with proper safeguards)
warningMsg := ""
if isInteractiveCommand(input.Script) {
warningMsg = "Warning: Command appears to be interactive and may hang. "
}

// Create a context with timeout
cmdCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()

// Get shell command from environment variable (default: "sh")
// Get shell command from environment variable (default: "sh -c")
shellCmd := getShellCommand()

// Parse shell command - support both single command and command with args
Expand All @@ -97,11 +122,24 @@ func ExecuteCommand(ctx context.Context, req *mcp.CallToolRequest, input Execute
cmd.Dir = workDir
}

// CRITICAL FIX: Set process group to enable killing entire process tree
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true, // Create new process group
}

// Create buffers to capture stdout and stderr separately
var stdoutBuf, stderrBuf bytes.Buffer
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf

// Set environment variables to force non-interactive mode
env := os.Environ()
env = append(env, "CI=true") // Common flag for CI/CD to disable interactive mode
env = append(env, "TERM=dumb") // Force dumb terminal
env = append(env, "INPUT=/dev/null") // Redirect stdin from /dev/null
env = append(env, "NONINTERACTIVE=1") // Common flag for non-interactive mode
cmd.Env = env

// Execute command
err := cmd.Run()

Expand All @@ -119,12 +157,23 @@ func ExecuteCommand(ctx context.Context, req *mcp.CallToolRequest, input Execute
} else {
// Context timeout or other error
if cmdCtx.Err() == context.DeadlineExceeded {
errorMsg = "Command timed out"
errorMsg = "Command timed out after " + strconv.Itoa(timeout) + " seconds"

// CRITICAL: Kill the entire process group on timeout
if cmd.Process != nil && cmd.Process.Pid > 0 {
// Kill the process group (negative PID)
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
}
exitCode = -1
}
}

// Add warning to stderr if interactive command detected
if warningMsg != "" {
stderrBuf.WriteString("\n" + warningMsg)
}

output := ExecuteCommandOutput{
Script: input.Script,
Stdout: stdoutBuf.String(),
Expand Down
Loading